# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for the one-pass dunder baking.
The single-field ``prebake_dunder`` and ``prebake_version`` helpers are covered
in ``test_version.py``, beside the version option that reads what they write.
The project metadata readers are covered in ``test_project.py``.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from click_extra.cli import demo
from click_extra.prebake import prebake_dunders
PYPROJECT_SCRIPTS = (
'[project]\nname = "orchard"\n\n[project.scripts]\norchard = "orchard.cli:main"\n'
)
"""A ``pyproject.toml`` declaring one console script."""
[docs]
@pytest.fixture
def src_project(tmp_path):
"""A src-layout project declaring one console script.
Its package carries two empty placeholders: a build field, which always
resolves, and a git field, which cannot resolve outside a repository.
"""
package = tmp_path / "src" / "orchard"
package.mkdir(parents=True)
(package / "__init__.py").write_text(
'__version__ = "1.0.0"\n__build_os__ = ""\n__git_branch__ = ""\n',
encoding="UTF-8",
)
(tmp_path / "pyproject.toml").write_text(PYPROJECT_SCRIPTS, encoding="UTF-8")
return tmp_path
# --- prebake_dunders ----------------------------------------------------------
[docs]
def test_prebake_dunders_fills_every_empty_placeholder_in_one_pass(tmp_path):
"""Every empty placeholder is filled, and only the resolvers it needs run.
A placeholder already holding a value and a field with no placeholder at all
never trigger their resolver, so a build does not shell out for nothing.
"""
source = tmp_path / "__init__.py"
source.write_text(
'__git_branch__ = ""\n__build_os__ = \'\'\n__git_tag__ = "v1"\n',
encoding="UTF-8",
)
calls: list[str] = []
def resolver(name: str, value: str):
def resolve() -> str:
calls.append(name)
return value
return resolve
baked = prebake_dunders(
source,
{
"__git_branch__": resolver("__git_branch__", "main"),
"__build_os__": "Linux",
"__git_tag__": resolver("__git_tag__", "v2"),
"__git_dirty__": resolver("__git_dirty__", "clean"),
},
)
assert baked == {"__git_branch__": "main", "__build_os__": "Linux"}
assert calls == ["__git_branch__"]
assert source.read_text(encoding="UTF-8") == (
'__git_branch__ = "main"\n__build_os__ = \'Linux\'\n__git_tag__ = "v1"\n'
)
[docs]
def test_prebake_dunders_leaves_a_placeholder_its_resolver_cannot_fill(tmp_path):
"""A resolver answering nothing leaves its placeholder, and the file, alone."""
source = tmp_path / "__init__.py"
source.write_text('__git_branch__ = ""\n', encoding="UTF-8")
before = source.stat().st_mtime_ns
assert prebake_dunders(source, {"__git_branch__": lambda: None}) == {}
assert source.read_text(encoding="UTF-8") == '__git_branch__ = ""\n'
assert source.stat().st_mtime_ns == before
[docs]
def test_prebake_dunders_fills_two_placeholders_sharing_a_line(tmp_path):
"""Two literals on one line both land, whatever the first one's new length."""
source = tmp_path / "__init__.py"
source.write_text('__a__ = ""; __b__ = ""\n', encoding="UTF-8")
baked = prebake_dunders(source, {"__a__": "a longer value", "__b__": "b"})
assert baked == {"__a__": "a longer value", "__b__": "b"}
assert (
source.read_text(encoding="UTF-8") == '__a__ = "a longer value"; __b__ = "b"\n'
)
[docs]
@pytest.mark.parametrize(
("placeholder", "value", "expected"),
(
pytest.param('""', "main", '"main"', id="double-quotes"),
pytest.param("''", "main", "'main'", id="single-quotes"),
pytest.param('""', 'say "cheese"', '"say \\"cheese\\""', id="quote-in-value"),
pytest.param("''", "kiwi's", "'kiwi\\'s'", id="apostrophe-in-value"),
pytest.param('""', "a\\b", '"a\\\\b"', id="backslash-in-value"),
pytest.param('""""""', "main", '"""main"""', id="triple-quotes"),
pytest.param('r""', "a\\b", '"a\\\\b"', id="raw-prefix"),
pytest.param('("" "")', "main", '("main")', id="implicit-concatenation"),
),
)
def test_prebake_dunders_writes_a_literal_holding_the_value(
tmp_path, placeholder, value, expected
):
"""The baked literal parses back to the value, in the quotes it opened with.
A value can carry the quote character the placeholder is written in, like a
branch named after a fruit's owner: escaped, it cannot close the literal
early, and the file still compiles.
"""
source = tmp_path / "__init__.py"
source.write_text(f"__git_branch__ = {placeholder}\n", encoding="UTF-8")
assert prebake_dunders(source, {"__git_branch__": value}) == {
"__git_branch__": value
}
baked = source.read_text(encoding="UTF-8")
assert baked == f"__git_branch__ = {expected}\n"
namespace: dict[str, str] = {}
exec(baked, namespace)
assert namespace["__git_branch__"] == value
[docs]
def test_prebake_dunders_counts_columns_past_non_ascii_text(tmp_path):
"""A placeholder after a non-ASCII literal on the same line lands in place.
The parser counts columns in UTF-8 bytes: read as characters, every
multi-byte character ahead of the placeholder would shift the splice right.
"""
source = tmp_path / "__init__.py"
source.write_text(
'__dessert__ = "crème brûlée"; __git_tag__ = ""\n', encoding="UTF-8"
)
prebake_dunders(source, {"__git_tag__": "v1.0"})
assert (
source.read_text(encoding="UTF-8")
== '__dessert__ = "crème brûlée"; __git_tag__ = "v1.0"\n'
)
# --- prebake all --------------------------------------------------------------
[docs]
def test_prebake_all_bakes_a_src_layout_project(invoke, src_project, monkeypatch):
"""``prebake all`` finds the package of a src-layout project on its own.
A build field always resolves, so its placeholder proves the file was found
and written. The git field stays empty outside a repository.
"""
monkeypatch.chdir(src_project)
result = invoke(demo, ["prebake", "all"])
assert result.exit_code == 0, result.output
init_path = Path("src") / "orchard" / "__init__.py"
baked = init_path.read_text(encoding="UTF-8")
assert '__build_os__ = ""' not in baked
assert '__version__ = "1.0.0"' in baked
assert f"Pre-baked {init_path}: __build_os__ = " in result.output