# 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.
"""Test the `--version` option."""
from __future__ import annotations
import copy
import io
import json
import logging
import os
import re
import shutil
import socket
import subprocess
import sys
import types
import warnings
from functools import cached_property, partial
from importlib import metadata
from textwrap import dedent
import click
import pytest
from click_extra import (
Style,
VersionOption,
__version__,
color_option,
command,
echo,
group,
no_color_option,
pass_context,
strip_ansi,
verbosity_option,
version_option,
)
from click_extra.cli import demo
from click_extra.color import forced_color
from click_extra.commands import default_params
from click_extra.prebake import (
prebake_dunder,
prebake_version,
)
from click_extra.pytest import (
command_decorators,
default_debug_colored_log_end,
default_debug_colored_logging,
default_debug_colored_version_details,
)
from click_extra.version import (
VersionScreen,
colors_reach_output,
is_main_module,
)
from click_extra.version_facts import (
BUILD_RESOLVERS,
SOURCE_DATE_EPOCH,
archival_field,
default_facts,
direct_url_field,
env_summary,
find_archival_file,
find_git_root,
read_archival,
read_direct_url,
resolve_build_time,
resolve_git_dirty,
resolve_git_distance,
run_git,
)
from .conftest import skip_windows_colors
# Regex matching the version with optional PEP 440 local version suffix for dev
# versions (like "7.6.0.dev0+abc1234").
_ver = re.escape(__version__) + r"(\+[a-f0-9]{4,40})?"
[docs]
@skip_windows_colors
@pytest.mark.parametrize("cmd_decorator", command_decorators())
@pytest.mark.parametrize("option_decorator", (version_option, version_option()))
def test_standalone_version_option(invoke, cmd_decorator, option_decorator):
@cmd_decorator
@option_decorator
def standalone_option():
echo("It works!")
result = invoke(standalone_option, "--version", color=True)
assert re.fullmatch(
rf"\x1b\[97m\x1b\[1mstandalone-option\x1b\[0m, version \x1b\[32m{_ver}\x1b\[0m\n",
result.output,
)
assert result.exit_code == 0
[docs]
@skip_windows_colors
@pytest.mark.parametrize("cmd_decorator", command_decorators())
@pytest.mark.parametrize("option_decorator", (version_option, version_option()))
def test_debug_output(invoke, cmd_decorator, option_decorator, assert_output_regex):
@cmd_decorator
@verbosity_option
@option_decorator
def debug_output():
echo("It works!")
result = invoke(debug_output, "--verbosity", "DEBUG", "--version", color=True)
assert_output_regex(
result.output,
(
default_debug_colored_logging
+ default_debug_colored_version_details
+ r"\x1b\[97m\x1b\[1mdebug-output\x1b\[0m, "
rf"version \x1b\[32m{_ver}\x1b\[0m\n" + default_debug_colored_log_end
),
)
[docs]
@skip_windows_colors
def test_set_version(invoke):
@click.group
@version_option(fields={"version": "1.2.3.4"})
def color_cli2():
echo("It works!")
# Test default coloring.
result = invoke(color_cli2, "--version", color=True)
assert result.stdout == (
"\x1b[97m\x1b[1mcolor-cli2\x1b[0m, version \x1b[32m1.2.3.4\x1b[0m\n"
)
assert not result.stderr
assert result.exit_code == 0
[docs]
@skip_windows_colors
def test_set_version_positional(invoke):
"""Click drop-in: an explicit version may be the first positional argument.
``@version_option("1.2.3.4")`` is equivalent to the
``fields={"version": "1.2.3.4"}`` form used by :func:`test_set_version`.
"""
@click.group
@version_option("1.2.3.4")
def color_cli2b():
echo("It works!")
result = invoke(color_cli2b, "--version", color=True)
assert result.stdout == (
"\x1b[97m\x1b[1mcolor-cli2b\x1b[0m, version \x1b[32m1.2.3.4\x1b[0m\n"
)
assert not result.stderr
assert result.exit_code == 0
[docs]
def test_version_option_dashed_positional_is_flag():
"""A leading-dash positional names the option flag, not the version."""
@click.command
@version_option("--app-version")
def cli():
pass
opt = next(p for p in cli.params if isinstance(p, VersionOption))
assert opt.opts == ["--app-version"]
[docs]
def test_version_option_conflicting_version():
"""The version cannot be supplied both positionally and via fields=."""
with pytest.raises(TypeError, match="both positionally and via fields"):
version_option("1.2.3", fields={"version": "9.9"})
[docs]
@skip_windows_colors
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
@pytest.mark.parametrize(
"message, regex_stdout",
(
(
"{prog_name}, version {version}",
(
r"\x1b\[97m\x1b\[1mcolor-cli3\x1b\[0m, "
rf"version \x1b\[32m{_ver}"
r"\x1b\[0m\n"
),
),
(
"{prog_name} v{version} - {package_name}",
(
r"\x1b\[97m\x1b\[1mcolor-cli3\x1b\[0m "
rf"v\x1b\[32m{_ver}"
r"\x1b\[0m - "
r"\x1b\[97m\x1b\[1mclick_extra"
r"\x1b\[0m\n"
),
),
(
"{prog_name}, version {version} (from {module.__name__})",
(
r"\x1b\[97m\x1b\[1mcolor-cli3\x1b\[0m, "
rf"version \x1b\[32m{_ver}\x1b\[0m "
r"\(from [\w.]+\)\n"
),
),
),
)
def test_custom_message(
invoke, cmd_decorator, message, regex_stdout, assert_output_regex
):
@cmd_decorator
@version_option(message=message)
def color_cli3():
echo("It works!")
result = invoke(color_cli3, "--version", color=True)
assert_output_regex(result.output, regex_stdout)
assert not result.stderr
assert result.exit_code == 0
[docs]
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
def test_style_reset(invoke, cmd_decorator):
@cmd_decorator
@version_option(
message_style=None,
styles={"version": None, "prog_name": None},
)
def color_reset():
pass
result = invoke(color_reset, "--version", color=True)
assert result.output == strip_ansi(result.output)
assert not result.stderr
assert result.exit_code == 0
[docs]
@skip_windows_colors
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
def test_custom_message_style(invoke, cmd_decorator):
@cmd_decorator
@version_option(
message="{prog_name} v{version} - {package_name} (latest)",
message_style=Style(fg="cyan"),
styles={
"prog_name": Style(fg="green", bold=True),
"version": Style(fg="bright_yellow", bg="red"),
"package_name": Style(fg="bright_blue", italic=True),
},
)
def custom_style():
pass
result = invoke(custom_style, "--version", color=True)
assert re.fullmatch(
r"\x1b\[32m\x1b\[1mcustom-style\x1b\[0m\x1b\[36m "
rf"v\x1b\[0m\x1b\[93m\x1b\[41m{_ver}\x1b\[0m\x1b\[36m - "
r"\x1b\[0m\x1b\[94m\x1b\[3mclick_extra\x1b\[0m\x1b\[36m \(latest\)\x1b\[0m\n",
result.output,
)
assert not result.stderr
assert result.exit_code == 0
[docs]
@pytest.mark.once
@pytest.mark.parametrize("field_id", sorted(BUILD_RESOLVERS))
def test_build_resolver_answers_and_is_a_template_field(field_id):
"""Every build resolver answers on the host running it, and has a field.
A build fact is unlike a git one on both counts: nothing can fail to
resolve, since the host is right here, and nothing can fall back later, so
a resolver whose field is missing from the template would bake a value no
message could ever print.
"""
value = BUILD_RESOLVERS[field_id]()
assert value
assert value.strip() == value
assert field_id in VersionOption.template_fields
assert hasattr(VersionOption, field_id)
[docs]
@pytest.mark.once
def test_build_time_honors_source_date_epoch(monkeypatch):
"""A reproducible build pins the stamp instead of reading the clock."""
monkeypatch.setenv(SOURCE_DATE_EPOCH, "1234567890")
assert resolve_build_time() == "2009-02-13T23:31:30Z"
[docs]
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
def test_context_meta(invoke, cmd_decorator, assert_output_regex):
@cmd_decorator
@version_option # type: ignore[arg-type]
@pass_context
def version_metadata(ctx):
for field in VersionOption.template_fields:
value = ctx.meta[f"click_extra.{field}"]
echo(f"{field} = {value}")
result = invoke(version_metadata, color=True)
assert_output_regex(
result.output,
(
r"module = <module 'click_extra\.testing' from '.+testing\.py'>\n"
r"module_name = click_extra\.testing\n"
r"module_file = .+testing\.py\n"
r"module_version = None\n"
r"package_name = click_extra\n"
r"package_version = \S+\n"
r"author = .+\n"
r"license = .+\n"
r"exec_name = click_extra\.testing\n"
r"version = \S+\n"
r"git_repo_path = .+\n"
r"git_branch = .+\n"
r"git_long_hash = (?:[a-f0-9]{40}|None)\n"
r"git_short_hash = (?:[a-f0-9]{4,40}|None)\n"
r"git_date = (?:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4}|None)\n"
r"git_tag = \S+\n"
r"git_tag_sha = None\n"
r"git_distance = (?:\d+|None)\n"
r"git_dirty = (?:dirty|clean|None)\n"
r"build_time = None\n"
r"build_os = None\n"
r"build_target = None\n"
r"build_target_arch = None\n"
r"prog_name = version-metadata\n"
),
)
assert result.output == strip_ansi(result.output)
assert not result.stderr
assert result.exit_code == 0
[docs]
def test_debug_version_run_resolves_no_host_name(invoke, monkeypatch):
"""A `--verbosity DEBUG` run lists every version field without a DNS query.
A reverse DNS query blocks for as long as the resolver takes to answer,
which has no bound on a host whose resolver never does.
Asserting on the calls rather than on a duration keeps the guard away from
a timing threshold, which a loaded runner would flake on.
"""
calls: list[str] = []
def record_fqdn(*args: object) -> str:
calls.append("getfqdn")
return "resolved.example.com"
def record_addr(*args: object) -> tuple[str, list[str], list[str]]:
calls.append("gethostbyaddr")
return "resolved.example.com", [], []
monkeypatch.setattr(socket, "getfqdn", record_fqdn)
monkeypatch.setattr(socket, "gethostbyaddr", record_addr)
@command
def forecast():
"""Report the weather."""
result = invoke(forecast, "--verbosity", "DEBUG", "--version")
assert result.exit_code == 0
assert "Version string template variables:" in result.stderr
assert calls == []
[docs]
def test_debug_listing_reaches_a_level_below_debug(invoke, caplog):
"""A logger set below `DEBUG`, like a custom trace level, lists the fields too."""
@click.command
@version_option(fields={"version": "1.2.3"})
def forecast():
"""Report the weather."""
with caplog.at_level(logging.DEBUG - 5, logger="click_extra.version"):
result = invoke(forecast)
assert result.exit_code == 0
assert "Version string template variables:" in caplog.text
[docs]
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
def test_context_meta_laziness(invoke, cmd_decorator):
"""Accessing a single field from ``ctx.meta`` must not evaluate unrelated fields.
Ensures that the ``_LazyVersionDict`` defers property evaluation: reading
``click_extra.version`` should not trigger expensive properties like the git
fields.
"""
@cmd_decorator
@version_option(fields={"version": "1.0.0"})
@pass_context
def lazy_cli(ctx):
# Access only the version field.
echo(f"version = {ctx.meta['click_extra.version']}")
result = invoke(lazy_cli)
assert result.exit_code == 0
assert "version = 1.0.0" in result.output
# Retrieve the VersionOption instance from the command.
version_param = next(p for p in lazy_cli.params if isinstance(p, VersionOption))
# Fields that were never accessed should NOT have been cached.
assert "git_date" not in version_param.__dict__
assert "git_long_hash" not in version_param.__dict__
[docs]
def test_module_version_parent_package_fallback(monkeypatch):
"""``module_version`` falls back to parent package's ``__version__``.
Simulates the Nuitka use-case: a CLI whose module is ``myapp.__main__``
(no ``__version__``), with the parent package ``myapp`` providing it.
"""
# Create a fake parent package with __version__.
fake_parent = types.ModuleType("myapp")
fake_parent.__version__ = "1.2.3" # type: ignore[attr-defined]
fake_parent.__package__ = "myapp"
# Create a fake __main__ submodule without __version__.
fake_main = types.ModuleType("myapp.__main__")
fake_main.__package__ = "myapp"
monkeypatch.setitem(sys.modules, "myapp", fake_parent)
monkeypatch.setitem(sys.modules, "myapp.__main__", fake_main)
opt = VersionOption(["--version"])
# Bypass cli_frame resolution by setting the module directly.
monkeypatch.setattr(
type(opt),
"module",
property(lambda self: fake_main),
)
assert opt.module_version == "1.2.3"
[docs]
@pytest.mark.parametrize(
("module_name", "expected"),
(
("__main__", True),
("myapp.__main__", True),
("click_extra.__main__", True),
("myapp", False),
("myapp.__main__.nested", False),
("myapp.main", False),
("", False),
),
)
def test_is_main_module(module_name, expected):
assert is_main_module(module_name) is expected
[docs]
def test_main_entry_point_survives_absent_distribution_metadata(monkeypatch):
"""A compiled binary reads its version off the package it started in.
A Nuitka standalone binary ships no distribution metadata, so
``distribution_of()`` answers ``None`` for the CLI's own package just as it
does for ecosystem plumbing, and the CLI module resolves to
``click_extra.__main__``. That entry point must survive: traded for the root
command's callback module, it loses the ``__main__`` exemption and
``--version`` renders nothing at all.
"""
fake_main = types.ModuleType("click_extra.__main__")
fake_main.__package__ = "click_extra"
@click.command
def forecast():
"""A CLI whose callback sits outside the binary's entry point."""
# Nothing at all resolves to an installed distribution.
monkeypatch.setattr("click_extra.version.distribution_of", lambda name: None)
# Stand in for the frame ``cli_frame()`` falls back to: the outermost one,
# running in the entry point module.
frame = types.SimpleNamespace(
f_globals={"__name__": "click_extra.__main__"}, f_back=None
)
monkeypatch.setattr(VersionOption, "cli_frame", staticmethod(lambda: frame))
monkeypatch.setitem(sys.modules, "click_extra.__main__", fake_main)
option = VersionOption(["--version"])
with click.Context(forecast):
assert option.module is fake_main
# The pre-baked ``__version__`` of the parent package, which is the only
# place a metadata-less binary can read a version from.
assert option.module_version == __version__
[docs]
@pytest.mark.parametrize("module_name", ("orchard.__main__", "orchard.cli"))
def test_prebaked_fields_read_from_the_package(monkeypatch, module_name):
"""A field baked into the package `__init__.py` reaches the CLI module beside it.
`click-extra prebake all` writes its dunders into the `__init__.py` of each
package a console script enters, while the CLI sits in a sibling module.
"""
package = types.ModuleType("orchard")
package.__package__ = "orchard"
vars(package).update({"__git_tag__": "v1.2.3", "__build_os__": "Linux 6.1"})
cli = types.ModuleType(module_name)
cli.__package__ = "orchard"
monkeypatch.setitem(sys.modules, "orchard", package)
monkeypatch.setitem(sys.modules, module_name, cli)
option = VersionOption(["--version"])
option.pin_field("module", cli)
# No live Git and no archive: the baked value is the only source left.
option.pin_field("git_repo_path", None)
option.__dict__["_archival_data"] = {}
assert option.git_tag == "v1.2.3"
assert option.build_os == "Linux 6.1"
[docs]
def test_prebaked_fields_skip_the_package_of_ecosystem_plumbing(monkeypatch):
"""A walk ending on a `CliRunner` never reads Click Extra's own baked fields."""
monkeypatch.setattr("click_extra.__git_tag__", "v9.9.9", raising=False)
option = VersionOption(["--version"])
option.pin_field("module", sys.modules["click_extra.testing"])
option.pin_field("git_repo_path", None)
option.__dict__["_archival_data"] = {}
assert option.git_tag is None
[docs]
@pytest.mark.parametrize(
("module_name", "package", "expected"),
(
pytest.param("orchard.__main__", "orchard", "orchard", id="package_main"),
pytest.param("__main__", "orchard", "orchard", id="python_m"),
pytest.param("orchard.cli", "orchard", "orchard.cli", id="package_module"),
pytest.param("__main__", "", "weather.py", id="script"),
),
)
def test_exec_name(module_name, package, expected):
"""A `__main__` entry point is named after its package, however it started.
`python -m orchard` runs the entry point under the `__main__` name, while a
console script or a compiled binary imports it as `orchard.__main__`.
"""
module = types.ModuleType(module_name)
module.__package__ = package
module.__file__ = "/fruits/weather.py"
option = VersionOption(["--version"])
option.pin_field("module", module)
assert option.exec_name == expected
[docs]
def test_package_version_resolves_import_name_to_distribution(monkeypatch):
"""When ``package_name`` is an import name that differs from its
installed distribution name (``PIL`` vs ``Pillow``), ``package_version``
resolves it via ``packages_distributions()`` instead of returning ``None``.
"""
def fake_version(name):
if name == "pillow":
return "10.4.0"
raise metadata.PackageNotFoundError(name)
monkeypatch.setattr(metadata, "version", fake_version)
monkeypatch.setattr(
metadata,
"packages_distributions",
lambda: {"PIL": ["pillow"]},
)
opt = VersionOption(["--version"], fields={"package_name": "PIL"})
assert opt.package_version == "10.4.0"
[docs]
def test_package_version_ambiguous_import_name_returns_none(monkeypatch):
"""When an import name maps to several installed distributions,
``package_version`` returns ``None`` rather than guessing.
"""
def fake_version(name):
raise metadata.PackageNotFoundError(name)
monkeypatch.setattr(metadata, "version", fake_version)
monkeypatch.setattr(
metadata,
"packages_distributions",
lambda: {"plug": ["foo-plug", "bar-plug"]},
)
opt = VersionOption(["--version"], fields={"package_name": "plug"})
assert opt.package_version is None
[docs]
def test_package_version_unknown_returns_none(monkeypatch):
"""When the name resolves to no distribution, ``package_version`` returns
``None``.
"""
def fake_version(name):
raise metadata.PackageNotFoundError(name)
monkeypatch.setattr(metadata, "version", fake_version)
monkeypatch.setattr(metadata, "packages_distributions", dict)
opt = VersionOption(["--version"], fields={"package_name": "nonexistent"})
assert opt.package_version is None
[docs]
def test_cli_frame_fallback():
"""``cli_frame()`` falls back to the outermost frame when all frames are
from the Click ecosystem."""
# Rewriting f_globals["__name__"] mutates real module dicts. Record each
# dict's original __name__ so the rename is undone; otherwise the bogus name
# leaks into every later test that reads that module's __name__.
saved_names: dict[int, tuple[dict, object]] = {}
missing = object()
try:
# Make every frame of the stack look like it belongs to click_extra, so
# the heuristic skips them all. The frame running cli_frame() lives in
# click_extra.version already.
frame: types.FrameType | None = sys._getframe()
while frame is not None:
frame_globals = frame.f_globals
saved_names.setdefault(
id(frame_globals),
(frame_globals, frame_globals.get("__name__", missing)),
)
frame_globals["__name__"] = "click_extra." + frame.f_code.co_name
frame = frame.f_back
# Should not raise RuntimeError; instead falls back to outermost frame,
# which has no caller: `module` reads that as a walk that ran out.
assert VersionOption.cli_frame().f_back is None
finally:
for frame_globals, original in saved_names.values():
if original is missing:
frame_globals.pop("__name__", None)
else:
frame_globals["__name__"] = original
STANDALONE_SCRIPT = dedent("""\
import click
from click_extra import echo, version_option
@click.command
@version_option(
message=(
"{prog_name} | {exec_name} | {package_name}"
" | {module_version} | {version}"
)
)
def weather():
echo("Sunny.")
if __name__ == "__main__":
weather()
""")
"""A CLI run straight from a file as `__main__`, with no package around it.
Its version message renders every field the unpackaged case resolves on its
own, so a single invocation pins them all.
"""
[docs]
@pytest.mark.parametrize(
("dunder", "expected_version"),
(
pytest.param('__version__ = "1.2.3"\n\n', "1.2.3", id="with_dunder"),
pytest.param("", "None", id="without_dunder"),
),
)
def test_standalone_script(tmp_path, dunder, expected_version):
"""A standalone script is named after its file, and versioned by `__version__`.
With no package to read metadata from, `exec_name` falls back to the
script's file name and `package_name` to `None`. The version is read from
a `__version__` variable defined alongside the CLI, and stays `None` when
the script defines none.
Only a real interpreter reaches that code path: a CLI declared inside a
test function belongs to the test module, and that module has a package.
"""
script = tmp_path / "weather.py"
script.write_text(dunder + STANDALONE_SCRIPT, encoding="UTF-8")
result = subprocess.run(
[sys.executable, str(script), "--version"],
capture_output=True,
text=True,
encoding="UTF-8",
cwd=tmp_path,
check=False,
)
assert result.returncode == 0, result.stderr
assert strip_ansi(result.stdout) == (
f"weather.py | weather.py | None | {expected_version} | {expected_version}\n"
)
COMMAND_STRING_CLI = dedent("""\
from click_extra import command, echo
__version__ = "1.2.3"
@command
def weather():
echo("Sunny.")
weather()
""")
"""A Click Extra CLI for `python -c`, which runs it with no file behind it."""
[docs]
@pytest.mark.parametrize(
("args", "expected"),
(
pytest.param((), "Sunny.\n", id="plain_run"),
pytest.param(("--version",), "weather, version 1.2.3\n", id="version"),
pytest.param(("--verbosity", "DEBUG"), "Sunny.\n", id="debug_fields"),
),
)
def test_command_string_cli(tmp_path, args, expected):
"""A CLI run by `python -c` resolves its version fields without a file.
The version option of a Click Extra command looks for the CLI's module on
every run, not only on `--version`. The debug level renders every field,
including the ones read off the module's file: `exec_name` keeps the
`__main__` module name.
"""
result = subprocess.run(
[sys.executable, "-c", COMMAND_STRING_CLI, *args],
capture_output=True,
text=True,
encoding="UTF-8",
cwd=tmp_path,
check=False,
)
assert result.returncode == 0, result.stderr
assert strip_ansi(result.stdout) == expected
if "DEBUG" in args:
assert re.search(
r"^debug: \{exec_name\} +: __main__$",
strip_ansi(result.stderr),
re.MULTILINE,
)
NUITKA_RUNNER = dedent("""\
import sys
import {module} as user_main
sys.exit(user_main.{function}())
""")
"""What Nuitka compiles for `--main-entry-point={{name}}={{module}}:{{function}}`.
A `__main__` with no package and no `__version__`, which binds the entry point
module and calls its function: a compiled binary starts here.
"""
[docs]
@pytest.mark.parametrize(
("module", "function"),
(
pytest.param("click_extra.__main__", "main", id="main_function"),
pytest.param("click_extra.cli", "demo", id="command_object"),
),
)
def test_nuitka_runner_resolves_the_entry_point_version(tmp_path, module, function):
"""The runner of a compiled binary prints the version `python -m` prints.
An entry point function stops the stack walk in its own module. A command
object leaves no frame of its module on the stack, and the walk ends on the
runner: the module of the command's callback then names the CLI.
"""
runner = tmp_path / "click-extra.py"
runner.write_text(
NUITKA_RUNNER.format(module=module, function=function), encoding="UTF-8"
)
outputs = []
for argv in ((str(runner),), ("-m", "click_extra")):
result = subprocess.run(
[sys.executable, *argv, "--version"],
capture_output=True,
text=True,
encoding="UTF-8",
cwd=tmp_path,
check=False,
)
assert result.returncode == 0, result.stderr
outputs.append(strip_ansi(result.stdout))
assert outputs[1].startswith("Click Extra, version ")
assert "None" not in outputs[1]
assert outputs[0] == outputs[1]
[docs]
@pytest.mark.parametrize(
"runner_source",
(
pytest.param(
NUITKA_RUNNER.format(module="orchard.cli", function="cli"),
id="nuitka_runner",
),
pytest.param(
dedent("""\
import atexit
from orchard.cli import cli
atexit.register(cli.main, ["--version"], standalone_mode=False)
"""),
id="atexit_hook",
),
pytest.param(
dedent("""\
import threading
from orchard.cli import cli
thread = threading.Thread(
target=cli.main,
args=(["--version"],),
kwargs={"standalone_mode": False},
)
thread.start()
thread.join()
"""),
id="thread",
),
pytest.param(
dedent("""\
from concurrent.futures import ThreadPoolExecutor
from orchard.cli import cli
with ThreadPoolExecutor() as executor:
executor.submit(
cli.main, ["--version"], standalone_mode=False
).result()
"""),
id="thread_pool",
),
),
)
def test_package_version_without_a_cli_frame(tmp_path, runner_source):
"""A CLI reads its package version when no frame of its module is on the stack.
No distribution provides the package, as in a compiled binary, so the version
can only come from the `__version__` of its `__init__.py`. The runner Nuitka
compiles calls the command object from a packageless `__main__`. An `atexit`
hook calls it once the main module has left the stack, like the target of
`_thread.start_new_thread()` or a call from a C host: the walk then sees only
the Click ecosystem, and falls back to the `main()` of the command. A thread
and an executor call it from the standard library, where the walk stops.
"""
package = tmp_path / "orchard"
package.mkdir()
(package / "__init__.py").write_text('__version__ = "1.2.3"\n', encoding="UTF-8")
(package / "cli.py").write_text(
dedent("""\
from click_extra import command, echo
@command
def cli():
echo("Sunny.")
"""),
encoding="UTF-8",
)
runner = tmp_path / "orchard-runner.py"
runner.write_text(runner_source, encoding="UTF-8")
result = subprocess.run(
[sys.executable, str(runner), "--version"],
capture_output=True,
text=True,
encoding="UTF-8",
cwd=tmp_path,
check=False,
)
assert result.returncode == 0, result.stderr
assert strip_ansi(result.stdout) == "cli, version 1.2.3\n"
SUBCOMMAND_VERSION_CLI = dedent("""\
from click_extra import echo, group, version_option
@group
def orchard():
pass
@orchard.command()
@version_option(fields={"version": "1.2.3"})
def harvest():
echo("Apples picked.")
orchard()
""")
"""A group whose subcommand carries a version option of its own."""
[docs]
@pytest.mark.parametrize(
("args", "expected"),
(
pytest.param(("harvest",), "Apples picked.\n", id="run"),
pytest.param(
("harvest", "--version"), "orchard, version 1.2.3\n", id="version"
),
),
)
def test_subcommand_version_option_outside_a_test_runner(tmp_path, args, expected):
"""The version option of a subcommand resolves where no test runner is loaded.
The stack walk of a subcommand crosses the `invoke()` of its group, which it
checks against the test runner of Click. A process that never imported
`click.testing` has no such runner to check against: a test process always
has, so only a new interpreter can tell.
"""
result = subprocess.run(
[sys.executable, "-c", SUBCOMMAND_VERSION_CLI, *args],
capture_output=True,
text=True,
encoding="UTF-8",
cwd=tmp_path,
check=False,
)
assert result.returncode == 0, result.stderr
assert strip_ansi(result.stdout) == expected
[docs]
@pytest.mark.parametrize(
"params",
(None, "--help", "blah", ("--config", "random.toml")),
)
def test_integrated_version_option_precedence(invoke, params):
def versioned_extra_params():
params = default_params()
for p in params:
if isinstance(p, VersionOption):
p.pin_field("version", "1.2.3.4")
return params
@group(params=versioned_extra_params)
def color_cli4():
echo("It works!")
result = invoke(color_cli4, "--version", params, color=True)
assert result.stdout == (
"\x1b[97m\x1b[1mcolor-cli4\x1b[0m, version \x1b[32m1.2.3.4\x1b[0m\n"
)
assert not result.stderr
assert result.exit_code == 0
[docs]
@skip_windows_colors
def test_version_fields_forwarded_to_version_option(invoke):
"""``version_fields`` on ``@command`` forwards to ``VersionOption``."""
@command(name="my-tool", version_fields={"prog_name": "My Tool"})
def prog_name_cli():
echo("It works!")
# prog_name controls --version output.
result = invoke(prog_name_cli, "--version", color=True)
assert "\x1b[97m\x1b[1mMy Tool\x1b[0m, version" in result.output
assert result.exit_code == 0
# name controls the usage line.
result = invoke(prog_name_cli, "--help", color=True)
assert "\x1b[97m\x1b[1mmy-tool\x1b[0m" in result.output
assert result.exit_code == 0
# All default extra options are preserved.
result = invoke(prog_name_cli, "--help", color=False)
assert "--time" in result.output
assert "--color" in result.output
assert "--config" in result.output
assert "--version" in result.output
[docs]
@skip_windows_colors
def test_version_fields_forwarded_on_group(invoke):
"""``version_fields`` works on ``@group`` too."""
@group(name="my-grp", version_fields={"prog_name": "My Group"})
def prog_name_grp():
pass
result = invoke(prog_name_grp, "--version", color=True)
assert "\x1b[97m\x1b[1mMy Group\x1b[0m, version" in result.output
assert result.exit_code == 0
result = invoke(prog_name_grp, "--help", color=True)
assert "\x1b[97m\x1b[1mmy-grp\x1b[0m" in result.output
assert result.exit_code == 0
[docs]
@skip_windows_colors
def test_version_fields_multiple(invoke):
"""Multiple fields can be overridden at once."""
@command(
version_fields={
"prog_name": "Acme CLI",
"version": "42.0",
"git_branch": "release/42",
},
)
def multi_cli():
pass
result = invoke(
multi_cli,
"--version",
color=False,
)
assert result.exit_code == 0
assert "Acme CLI" in result.output
assert "42.0" in result.output
[docs]
def test_version_fields_rejects_unknown(invoke):
"""Unknown field names raise ``TypeError``."""
with pytest.raises(TypeError, match="Unknown version field 'bogus'"):
@command(version_fields={"bogus": "oops"})
def bad_cli():
pass
[docs]
@pytest.mark.parametrize(
"build",
(
pytest.param(lambda: VersionOption(fields={"bogus": "1.2.3"}), id="fields"),
pytest.param(lambda: VersionOption(styles={"bogus": None}), id="styles"),
pytest.param(lambda: VersionOption().pin_field("bogus", "1.2.3"), id="pin"),
),
)
def test_unknown_field_is_rejected_the_same_way_everywhere(build):
"""Every way to name a template field rejects an unknown one alike."""
with pytest.raises(TypeError, match="Unknown version field 'bogus'"):
build()
[docs]
@skip_windows_colors
def test_color_option_precedence(invoke):
"""A plain ``click.command`` only decolors ``--version`` when ``--no-color``
precedes it on the command line.
Click evaluates eager parameters in the order the user typed them (`callback
evaluation order
<https://click.palletsprojects.com/en/stable/click-concepts/#callback-evaluation-order>`_),
so a ``--no-color`` placed after ``--version`` lands too late: the version screen
has already rendered in color and exited.
click-extra's own ``@command`` settles the color options in a pre-pass before any
eager screen renders, so the color choice is honored whatever its position. See
``Command._resolve_presentation_eagerly`` and the order-independent
``test_color_settles_before_eager_help_and_version`` in ``test_color.py``. This
test pins the residual behavior of the plain-Click path, which that pre-pass does
not reach.
"""
@click.command
@color_option
@no_color_option
@version_option(fields={"version": "2.1.9"})
def color_cli6():
echo(Style(fg="yellow")("It works!"))
result = invoke(color_cli6, "--no-color", "--version", "command1", color=True)
assert result.stdout == "color-cli6, version 2.1.9\n"
assert not result.stderr
assert result.exit_code == 0
result = invoke(color_cli6, "--version", "--no-color", "command1", color=True)
assert result.stdout == (
"\x1b[97m\x1b[1mcolor-cli6\x1b[0m, version \x1b[32m2.1.9\x1b[0m\n"
)
assert not result.stderr
assert result.exit_code == 0
[docs]
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
def test_dev_version_appends_git_hash(invoke, cmd_decorator):
"""A ``.dev`` version gets a ``+hash`` suffix appended (or not, if git is
unavailable)."""
@cmd_decorator
@version_option(fields={"module_version": "1.0.0.dev1"})
def dev_cli():
echo("It works!")
result = invoke(dev_cli, "--version", color=False)
ver = strip_ansi(result.output).split("version ")[-1].strip()
# Either plain dev version (no git) or with +hash suffix.
assert re.fullmatch(r"1\.0\.0\.dev1(\+[a-f0-9]{4,40})?", ver)
assert result.exit_code == 0
[docs]
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
def test_prebaked_dev_version_not_double_suffixed(invoke, cmd_decorator):
"""A version with an existing ``+`` is returned as-is: no second hash appended."""
@cmd_decorator
@version_option(fields={"module_version": "1.0.0.dev1+abc1234"})
def prebaked_cli():
echo("It works!")
result = invoke(prebaked_cli, "--version", color=False)
ver = strip_ansi(result.output).split("version ")[-1].strip()
assert ver == "1.0.0.dev1+abc1234"
assert result.exit_code == 0
[docs]
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
def test_release_version_unchanged(invoke, cmd_decorator):
"""A non-dev version is never modified."""
@cmd_decorator
@version_option(fields={"module_version": "2.5.0"})
def release_cli():
echo("It works!")
result = invoke(release_cli, "--version", color=False)
ver = strip_ansi(result.output).split("version ")[-1].strip()
assert ver == "2.5.0"
assert result.exit_code == 0
# --- prebake_version tests ---
[docs]
@pytest.fixture
def init_file(tmp_path):
"""Helper that creates a temporary __init__.py with the given content."""
def _make(content: str):
p = tmp_path / "__init__.py"
p.write_text(content, encoding="UTF-8")
return p
return _make
[docs]
def test_prebake_cli_resolves_module_from_config(invoke, tmp_path, monkeypatch):
"""``click-extra prebake`` resolves its target from ``[tool.click-extra.prebake]``."""
target = tmp_path / "pkg" / "__init__.py"
target.parent.mkdir()
target.write_text('__git_tag_sha__ = ""\n', encoding="UTF-8")
(tmp_path / "pyproject.toml").write_text(
f'[tool.click-extra.prebake]\nmodule = "{target.as_posix()}"\n',
encoding="UTF-8",
)
monkeypatch.chdir(tmp_path)
# No --module on the command line: the path comes from the config section.
result = invoke(demo, ["prebake", "field", "git_tag_sha", "abc123"])
assert result.exit_code == 0
assert '__git_tag_sha__ = "abc123"' in target.read_text(encoding="UTF-8")
[docs]
def test_prebake_dev_version(init_file):
"""A ``.dev`` version gets ``+hash`` appended in the file."""
p = init_file('__version__ = "1.0.0.dev0"\n')
result = prebake_version(p, local_version="abc1234")
assert result == "1.0.0.dev0+abc1234"
assert '__version__ = "1.0.0.dev0+abc1234"' in p.read_text()
[docs]
def test_prebake_single_quotes(init_file):
"""Single-quoted ``__version__`` is also handled."""
p = init_file("__version__ = '2.0.0.dev5'\n")
result = prebake_version(p, local_version="f00baa")
assert result == "2.0.0.dev5+f00baa"
assert "__version__ = '2.0.0.dev5+f00baa'" in p.read_text()
[docs]
def test_prebake_already_baked_skipped(init_file):
"""A version with existing ``+`` is left untouched."""
p = init_file('__version__ = "1.0.0.dev0+existing"\n')
result = prebake_version(p, local_version="abc1234")
assert result is None
assert '__version__ = "1.0.0.dev0+existing"' in p.read_text()
[docs]
def test_prebake_release_skipped(init_file):
"""A release version (no ``.dev``) is not modified."""
p = init_file('__version__ = "3.2.1"\n')
result = prebake_version(p, local_version="abc1234")
assert result is None
assert '__version__ = "3.2.1"' in p.read_text()
[docs]
def test_prebake_no_version_in_file(init_file):
"""A file without ``__version__`` returns ``None``."""
p = init_file('"""Just a docstring."""\n')
result = prebake_version(p, local_version="abc1234")
assert result is None
[docs]
def test_prebake_missing_local_version_raises(init_file):
"""Calling without ``local_version`` raises ``TypeError``."""
p = init_file('__version__ = "1.0.0.dev0"\n')
with pytest.raises(TypeError):
prebake_version(p) # type: ignore[call-arg]
[docs]
def test_prebake_idempotent(init_file):
"""Running prebake twice does not double-suffix."""
p = init_file('__version__ = "1.0.0.dev0"\n')
first = prebake_version(p, local_version="abc1234")
assert first == "1.0.0.dev0+abc1234"
second = prebake_version(p, local_version="def5678")
assert second is None
assert '__version__ = "1.0.0.dev0+abc1234"' in p.read_text()
[docs]
def test_prebake_preserves_surrounding_content(init_file):
"""Content around ``__version__`` is not disturbed."""
content = (
'"""My package."""\n'
"\n"
"from __future__ import annotations\n"
"\n"
'__version__ = "4.0.0.dev0"\n'
"\n"
"API_URL = 'https://example.com'\n"
)
p = init_file(content)
prebake_version(p, local_version="cafe123")
result = p.read_text()
assert '__version__ = "4.0.0.dev0+cafe123"' in result
assert "from __future__ import annotations" in result
assert "API_URL = 'https://example.com'" in result
# --- prebake_dunder tests ---
[docs]
def test_prebake_dunder_empty_replaced(init_file):
"""An empty dunder variable gets replaced."""
p = init_file('__git_tag_sha__ = ""\n')
result = prebake_dunder(p, "__git_tag_sha__", "abc123def456")
assert result == "abc123def456"
assert '__git_tag_sha__ = "abc123def456"' in p.read_text()
[docs]
def test_prebake_dunder_single_quotes(init_file):
"""Single-quoted empty dunder is also handled."""
p = init_file("__git_tag_sha__ = ''\n")
result = prebake_dunder(p, "__git_tag_sha__", "f00baa")
assert result == "f00baa"
assert "__git_tag_sha__ = 'f00baa'" in p.read_text()
[docs]
def test_prebake_dunder_nonempty_skipped(init_file):
"""A dunder with an existing non-empty value is left untouched."""
p = init_file('__git_tag_sha__ = "already_set"\n')
result = prebake_dunder(p, "__git_tag_sha__", "new_value")
assert result is None
assert '__git_tag_sha__ = "already_set"' in p.read_text()
[docs]
def test_prebake_dunder_not_found(init_file):
"""A file without the target dunder returns ``None``."""
p = init_file('__version__ = "1.0.0"\n')
result = prebake_dunder(p, "__git_tag_sha__", "abc123")
assert result is None
[docs]
def test_prebake_dunder_idempotent(init_file):
"""Running prebake_dunder twice does not overwrite."""
p = init_file('__git_tag_sha__ = ""\n')
first = prebake_dunder(p, "__git_tag_sha__", "abc123")
assert first == "abc123"
second = prebake_dunder(p, "__git_tag_sha__", "def456")
assert second is None
assert '__git_tag_sha__ = "abc123"' in p.read_text()
[docs]
def test_prebake_dunder_preserves_surrounding_content(init_file):
"""Content around the target dunder is not disturbed."""
content = (
'"""My package."""\n'
"\n"
"from __future__ import annotations\n"
"\n"
'__version__ = "4.0.0.dev0"\n'
'__git_tag_sha__ = ""\n'
"\n"
"API_URL = 'https://example.com'\n"
)
p = init_file(content)
prebake_dunder(p, "__git_tag_sha__", "cafe123")
result = p.read_text()
assert '__git_tag_sha__ = "cafe123"' in result
assert '__version__ = "4.0.0.dev0"' in result
assert "from __future__ import annotations" in result
assert "API_URL = 'https://example.com'" in result
[docs]
def test_prebake_dunder_full_sha(init_file):
"""A full 40-character SHA is handled correctly."""
sha = "072c7bbbcdd607011c6ca4fb9d5098532aee2dea"
p = init_file('__git_tag_sha__ = ""\n')
result = prebake_dunder(p, "__git_tag_sha__", sha)
assert result == sha
assert f'__git_tag_sha__ = "{sha}"' in p.read_text()
# --- prebaked git field resolution tests ---
[docs]
def test_prebaked_git_branch():
"""A pre-baked ``__git_branch__`` dunder is used over subprocess."""
mod = types.ModuleType("fake_cli")
mod.__git_branch__ = "release/1.0" # type: ignore[attr-defined]
mod.__file__ = "/fake/path.py"
mod.__package__ = "fake_cli"
opt = VersionOption()
# Bypass cli_frame by setting module directly.
opt.__dict__["module"] = mod
assert opt.git_branch == "release/1.0"
[docs]
def test_prebaked_git_long_hash():
"""A pre-baked ``__git_long_hash__`` dunder is used over subprocess."""
mod = types.ModuleType("fake_cli")
mod.__git_long_hash__ = "abc123def456" * 3 # type: ignore[attr-defined]
mod.__file__ = "/fake/path.py"
mod.__package__ = "fake_cli"
opt = VersionOption()
opt.__dict__["module"] = mod
assert opt.git_long_hash == "abc123def456" * 3
[docs]
def test_prebaked_git_tag_sha():
"""A pre-baked ``__git_tag_sha__`` dunder is resolved."""
sha = "072c7bbbcdd607011c6ca4fb9d5098532aee2dea"
mod = types.ModuleType("fake_cli")
mod.__git_tag_sha__ = sha # type: ignore[attr-defined]
mod.__file__ = "/fake/path.py"
mod.__package__ = "fake_cli"
opt = VersionOption()
opt.__dict__["module"] = mod
assert opt.git_tag_sha == sha
[docs]
def test_prebaked_empty_dunder_ignored():
"""An empty dunder is not treated as a pre-baked value."""
mod = types.ModuleType("fake_cli")
mod.__git_branch__ = "" # type: ignore[attr-defined]
mod.__file__ = "/fake/path.py"
mod.__package__ = "fake_cli"
opt = VersionOption()
opt.__dict__["module"] = mod
# Empty string should be ignored; falls back to git subprocess.
# Since there's no git repo, result is None.
assert opt.git_branch is None
[docs]
def test_prebaked_non_string_ignored():
"""A non-string dunder is not treated as a pre-baked value."""
mod = types.ModuleType("fake_cli")
mod.__git_branch__ = 42 # type: ignore[attr-defined]
mod.__file__ = "/fake/path.py"
mod.__package__ = "fake_cli"
opt = VersionOption()
opt.__dict__["module"] = mod
assert opt.git_branch is None
[docs]
def test_prebaked_git_distance():
"""A pre-baked ``__git_distance__`` dunder is used over subprocess."""
mod = types.ModuleType("fake_cli")
mod.__git_distance__ = "42" # type: ignore[attr-defined]
mod.__file__ = "/fake/path.py"
mod.__package__ = "fake_cli"
opt = VersionOption()
opt.__dict__["module"] = mod
assert opt.git_distance == "42"
[docs]
def test_prebaked_git_dirty():
"""A pre-baked ``__git_dirty__`` dunder is used over subprocess."""
mod = types.ModuleType("fake_cli")
mod.__git_dirty__ = "dirty" # type: ignore[attr-defined]
mod.__file__ = "/fake/path.py"
mod.__package__ = "fake_cli"
opt = VersionOption()
opt.__dict__["module"] = mod
assert opt.git_dirty == "dirty"
# --- resolve_git_distance / resolve_git_dirty tests ---
[docs]
@pytest.mark.parametrize(
("describe_output", "expected"),
(
(None, None),
("v1.2.3-0-gabcdef0", "0"),
("v1.2.3-4-gabcdef0", "4"),
("1.2.3-17-g0123abc", "17"),
("no-distance-here", None),
),
)
def test_resolve_git_distance(monkeypatch, describe_output, expected):
monkeypatch.setattr(
"click_extra.version_facts.run_git", lambda *a, **k: describe_output
)
assert resolve_git_distance() == expected
[docs]
@pytest.mark.parametrize(
("status_output", "expected"),
(
(None, None),
("", "clean"),
(" M file.py", "dirty"),
("?? new.py", "dirty"),
),
)
def test_resolve_git_dirty(monkeypatch, status_output, expected):
monkeypatch.setattr(
"click_extra.version_facts.run_git", lambda *a, **k: status_output
)
assert resolve_git_dirty() == expected
[docs]
@pytest.mark.parametrize(
("branch", "expected"),
(
# The `Á` holds a `0x81` byte, which cp1252, the encoding of Windows,
# cannot decode.
pytest.param("Ávila".encode(), "Ávila", id="utf-8"),
# typos splits the literal at the escape and reads the fragment before it
# as a misspelling of `calf`, so the word here is "creme".
pytest.param(b"cr\xe8me", "cr\ufffdme", id="latin-1"),
),
)
def test_run_git_decodes_a_branch_name(tmp_path, branch, expected):
"""A branch name decodes as UTF-8, and a byte that is not becomes a replacement
character.
The branch goes in `packed-refs`: a loose ref is a file, and APFS refuses a
file name that is no UTF-8.
"""
git = (
"git",
"-C",
str(tmp_path),
"-c",
"user.name=Ada",
"-c",
"user.email=ada@example.org",
"-c",
"commit.gpgsign=false",
)
subprocess.run((*git, "init", "--quiet"), check=True)
subprocess.run(
(*git, "commit", "--quiet", "--allow-empty", "--message", "Bake bread"),
check=True,
)
sha = subprocess.run(
(*git, "rev-parse", "HEAD"), capture_output=True, check=True
).stdout.strip()
(tmp_path / ".git" / "packed-refs").write_bytes(
sha + b" refs/heads/" + branch + b"\n"
)
(tmp_path / ".git" / "HEAD").write_bytes(b"ref: refs/heads/" + branch + b"\n")
assert run_git("rev-parse", "--abbrev-ref", "HEAD", cwd=tmp_path) == expected
[docs]
def test_run_git_returns_nothing_or_raises_on_failure(tmp_path, monkeypatch):
"""A failing command returns `None`, or raises when asked to."""
# A folder outside any repository: the command exits with an error.
assert run_git("rev-parse", "HEAD", cwd=tmp_path) is None
with pytest.raises(subprocess.CalledProcessError):
run_git("rev-parse", "HEAD", cwd=tmp_path, check=True)
# No binary to run at all.
monkeypatch.setenv("PATH", "")
assert run_git("--version") is None
with pytest.raises(OSError):
run_git("--version", check=True)
[docs]
def test_run_git_gives_up_after_its_timeout(tmp_path, monkeypatch):
"""A command running past its timeout returns `None`, or raises when asked."""
def stalled(*args, **kwargs):
raise subprocess.TimeoutExpired(args[0], kwargs["timeout"])
monkeypatch.setattr(subprocess, "run", stalled)
assert run_git("status", cwd=tmp_path, timeout=0.1) is None
with pytest.raises(subprocess.TimeoutExpired) as excinfo:
run_git("status", cwd=tmp_path, check=True, timeout=0.1)
assert excinfo.value.timeout == pytest.approx(0.1)
[docs]
def test_run_git_keeps_an_empty_last_field(tmp_path):
"""Only the final newline goes: the empty field ending a line stays.
A lightweight tag points at no tag object, so the date read through one is
empty, which ends the line on a tab.
"""
repo = (
"git",
"-C",
str(tmp_path),
"-c",
"user.name=Ada",
"-c",
"user.email=ada@example.org",
"-c",
"commit.gpgsign=false",
)
subprocess.run((*repo, "init", "--quiet"), check=True)
subprocess.run(
(*repo, "commit", "--quiet", "--allow-empty", "--message", "Plant a tree"),
check=True,
)
subprocess.run((*repo, "tag", "seedling"), check=True)
listing = run_git(
"for-each-ref",
"--format=%(refname:strip=2)%09%(*authordate:short)",
"refs/tags",
cwd=tmp_path,
)
assert listing == "seedling\t"
[docs]
def test_find_git_root(tmp_path):
"""The walk stops at the nearest `.git` entry, a file included."""
orchard = tmp_path / "orchard"
tree = orchard / "row" / "tree"
tree.mkdir(parents=True)
(orchard / ".git").mkdir()
assert find_git_root(tree) == orchard
assert find_git_root(orchard) == orchard
# A worktree or a submodule holds a `.git` file instead of a folder.
(tree / ".git").write_text("gitdir: elsewhere\n", encoding="UTF-8")
assert find_git_root(tree) == tree
# --- .git_archival.json fallback tests ---
SUBSTITUTED_ARCHIVAL = {
"node": "0123456789abcdef0123456789abcdef01234567",
"node-date": "2021-06-01T12:00:00+00:00",
"describe-name": "v1.2.3-4-g0123456",
"ref-names": "HEAD -> main, tag: v1.2.3",
}
[docs]
@pytest.mark.parametrize(
("field_id", "expected"),
(
("git_long_hash", "0123456789abcdef0123456789abcdef01234567"),
("git_short_hash", "0123456"),
("git_date", "2021-06-01T12:00:00+00:00"),
("git_branch", "main"),
("git_tag", "v1.2.3"),
("git_tag_sha", "0123456789abcdef0123456789abcdef01234567"),
("git_distance", "4"),
("git_dirty", None),
),
)
def test_archival_field_substituted(field_id, expected):
assert archival_field(SUBSTITUTED_ARCHIVAL, field_id) == expected
[docs]
@pytest.mark.parametrize(
"field_id",
(
"git_long_hash",
"git_short_hash",
"git_date",
"git_branch",
"git_tag",
"git_tag_sha",
"git_distance",
),
)
def test_archival_field_unsubstituted_ignored(field_id):
"""A raw checkout keeps the $Format placeholders, which must be ignored."""
raw = {
"node": "$Format:%H$",
"node-date": "$Format:%cI$",
"describe-name": "$Format:%(describe:tags=true,match=*[0-9]*)$",
"ref-names": "$Format:%D$",
}
assert archival_field(raw, field_id) is None
[docs]
def test_archival_field_exact_tag_distance_zero():
"""A bare describe-name (no -N-g suffix) means distance zero."""
data = {"describe-name": "v1.2.3", "ref-names": "tag: v1.2.3"}
assert archival_field(data, "git_distance") == "0"
assert archival_field(data, "git_tag") == "v1.2.3"
[docs]
def test_read_archival_roundtrip(tmp_path):
path = tmp_path / ".git_archival.json"
path.write_text(json.dumps(SUBSTITUTED_ARCHIVAL), encoding="UTF-8")
assert read_archival(path) == SUBSTITUTED_ARCHIVAL
[docs]
def test_read_archival_invalid_json(tmp_path):
path = tmp_path / ".git_archival.json"
path.write_text("{not json", encoding="UTF-8")
assert read_archival(path) == {}
[docs]
def test_find_archival_file_walks_up(tmp_path):
(tmp_path / ".git_archival.json").write_text("{}", encoding="UTF-8")
nested = tmp_path / "pkg" / "sub"
nested.mkdir(parents=True)
assert find_archival_file(nested) == tmp_path / ".git_archival.json"
[docs]
def test_find_archival_file_absent(tmp_path):
assert find_archival_file(tmp_path) is None
[docs]
def test_archival_resolves_git_fields(tmp_path):
"""Git fields resolve from .git_archival.json when there is no live git."""
(tmp_path / ".git_archival.json").write_text(
json.dumps(SUBSTITUTED_ARCHIVAL), encoding="UTF-8"
)
pkg = tmp_path / "pkg"
pkg.mkdir()
mod = types.ModuleType("fake_cli")
mod.__file__ = str(pkg / "cli.py")
mod.__package__ = "fake_cli"
opt = VersionOption()
opt.__dict__["module"] = mod
# Force "no live git" so only the archival fallback is exercised.
opt.__dict__["git_repo_path"] = None
assert opt.git_long_hash == SUBSTITUTED_ARCHIVAL["node"]
assert opt.git_short_hash == "0123456"
assert opt.git_tag == "v1.2.3"
assert opt.git_tag_sha == SUBSTITUTED_ARCHIVAL["node"]
assert opt.git_branch == "main"
assert opt.git_distance == "4"
# No work tree in an archive, so dirtiness is unknowable.
assert opt.git_dirty is None
# --- direct_url.json fallback tests ---
GIT_COMMIT = "0123456789abcdef0123456789abcdef01234567"
GIT_DIRECT_URL = {
"url": "https://github.com/orchard/apricot",
"vcs_info": {"vcs": "git", "requested_revision": "main", "commit_id": GIT_COMMIT},
}
[docs]
def install_distribution(tmp_path, monkeypatch, direct_url: str | None) -> None:
"""Install a fake `apricot` distribution, with a `direct_url.json` holding
*direct_url* unless it is `None`."""
dist_info = tmp_path / "apricot-1.2.3.dev0.dist-info"
dist_info.mkdir()
(dist_info / "METADATA").write_text(
"Metadata-Version: 2.1\nName: apricot\nVersion: 1.2.3.dev0\n",
encoding="UTF-8",
)
if direct_url is not None:
(dist_info / "direct_url.json").write_text(direct_url, encoding="UTF-8")
monkeypatch.syspath_prepend(str(tmp_path))
[docs]
@pytest.mark.parametrize(
("data", "field_id", "expected"),
(
(GIT_DIRECT_URL, "git_long_hash", GIT_COMMIT),
(GIT_DIRECT_URL, "git_short_hash", "0123456"),
# A requested revision names a branch, a tag or a commit: none is guessed.
(GIT_DIRECT_URL, "git_branch", None),
(GIT_DIRECT_URL, "git_tag", None),
# Another VCS records a commit that is no Git hash.
(
{
"url": "https://hg.example/apricot",
"vcs_info": {"vcs": "hg", "commit_id": "a1b2"},
},
"git_long_hash",
None,
),
# A local directory, editable or not, records no commit.
(
{"url": "file:///orchard", "dir_info": {"editable": True}},
"git_long_hash",
None,
),
({}, "git_short_hash", None),
),
)
def test_direct_url_field(data, field_id, expected):
assert direct_url_field(data, field_id) == expected
[docs]
@pytest.mark.parametrize(
("direct_url", "expected"),
(
(json.dumps(GIT_DIRECT_URL), GIT_DIRECT_URL),
(None, {}),
("{not json", {}),
("[]", {}),
),
)
def test_read_direct_url(tmp_path, monkeypatch, direct_url, expected):
install_distribution(tmp_path, monkeypatch, direct_url)
assert read_direct_url("apricot") == expected
[docs]
@pytest.mark.parametrize("distribution", (None, "", "no-such-distribution"))
def test_read_direct_url_unknown_distribution(distribution):
assert read_direct_url(distribution) == {}
[docs]
def test_direct_url_resolves_git_hashes(tmp_path, monkeypatch):
"""A CLI installed from a Git URL names its commit, with no live git and no
archive."""
install_distribution(tmp_path, monkeypatch, json.dumps(GIT_DIRECT_URL))
mod = types.ModuleType("apricot.cli")
mod.__file__ = str(tmp_path / "apricot" / "cli.py")
mod.__package__ = "apricot"
opt = VersionOption()
opt.__dict__["module"] = mod
opt.__dict__["git_repo_path"] = None
opt.__dict__["_archival_data"] = {}
assert opt.git_long_hash == GIT_COMMIT
assert opt.git_short_hash == "0123456"
assert opt.version == "1.2.3.dev0+0123456"
assert opt.git_branch is None
# --- dev-version hash assembly ---
[docs]
@pytest.mark.parametrize(
("module_version", "expected"),
(
# Dev release: hash appended.
("1.0.0.dev1", "1.0.0.dev1+abc1234"),
# Release: never modified.
("1.0.0", "1.0.0"),
# Already has a local identifier: returned as-is.
("1.0.0.dev1+existing", "1.0.0.dev1+existing"),
# Non-canonical ".dev" spellings are not matched by the lightweight
# substring check, so no hash is appended.
("1.0.0-dev1", "1.0.0-dev1"),
),
)
def test_version_dev_hash_assembly(module_version, expected):
"""The git short hash is appended to dev releases only."""
opt = VersionOption(
fields={"module_version": module_version, "git_short_hash": "abc1234"},
)
assert opt.version == expected
# --- version screen layout ---
[docs]
@pytest.fixture
def terminal_width(monkeypatch):
"""Pin the width `VersionScreen.render` measures itself against.
The function is patched, and `COLUMNS` deliberately left alone.
`shutil.get_terminal_size` reads that variable before it measures the
terminal, so exporting it would pin the width just as well. It would also
resize pytest's own reports for as long as a narrow pin is held.
The replacement accepts keyword arguments because pytest measures its
progress line with `shutil.get_terminal_size(fallback=(80, 24))`. A
positional-only stand-in raises `TypeError` there and aborts the run with an
`INTERNALERROR`. See [#1934](https://github.com/kdeldycke/click-extra/pull/1934).
"""
def pin(columns: int) -> None:
monkeypatch.setattr(
shutil,
"get_terminal_size",
lambda *args, **kwargs: os.terminal_size((columns, 24)),
)
return pin
PLAIN_LOGO = ("###", "#", "#####")
"""A deliberately ragged mark: no caller should have to square its own artwork."""
STYLED_LOGO = ("\x1b[32m###\x1b[0m", "\x1b[31m#\x1b[0m")
"""The same, carrying color, to prove the measurement discounts escape sequences."""
[docs]
@pytest.mark.parametrize(
("logo", "width"),
(
(PLAIN_LOGO, 5),
("###\n#\n#####", 5),
(STYLED_LOGO, 3),
((), 0),
("", 0),
),
)
def test_screen_measures_its_logo(logo, width):
"""The width comes from the artwork, never from the caller."""
assert VersionScreen(logo=logo).width == width
[docs]
@pytest.mark.parametrize("logo", (PLAIN_LOGO, "###\n#\n#####", STYLED_LOGO))
def test_screen_squares_a_ragged_logo(logo):
"""Every line is padded out to the widest, so the facts seat against a block.
Padding here rather than demanding it is what lets a caller hand over whatever
its renderer produced. It cannot repair this afterwards even if it wanted to:
`str.ljust` counts the escape sequences it cannot see, so on a styled line it
silently does nothing.
"""
screen = VersionScreen(logo=logo)
assert {len(strip_ansi(line)) for line in screen.lines} == {screen.width}
[docs]
def test_screen_padding_closes_an_open_style():
"""Padding after an unterminated style cannot inherit it.
A mark whose last run is left open would otherwise paint its own trailing
blanks, and the gutter with them, dragging a background across the facts.
"""
screen = VersionScreen(logo=("\x1b[41mred", "wider than that"))
assert screen.lines[0].endswith("\x1b[0m" + " " * (screen.width - 3))
[docs]
def test_screen_facts_may_be_deferred():
"""A callable is not called until the screen is drawn.
The point of accepting one: a CLI reporting something costly should pay for it
when `--version` is asked for, not on every invocation that might have been.
"""
calls: list[None] = []
def facts():
calls.append(None)
return {"Answer": "42"}
screen = VersionScreen(logo="#", facts=facts)
assert calls == []
rows = screen.rows("cli", "1.2.3", {})
assert calls == [None]
assert rows[-1][0] == "Answer 42"
[docs]
def test_screen_draws_a_callable_logo_once_per_render(terminal_width):
"""Measuring and padding the mark share one drawing of it."""
calls: list[None] = []
def logo():
calls.append(None)
return PLAIN_LOGO
terminal_width(200)
assert VersionScreen(logo=logo).render("cli", "1.2.3", {}) is not None
assert len(calls) == 1
[docs]
def test_screen_sizes_its_label_column_to_the_longest_label():
"""No declared width to outgrow: the column is measured, then gutter-separated."""
screen = VersionScreen(logo="#", facts={"a": "1", "loooooong": "2"})
plain = [row[0] for row in screen.rows("cli", "1.2.3", {})]
assert plain[-2:] == ["a 1", "loooooong 2"]
[docs]
def test_screen_facts_replace_a_default_row_in_place():
"""`|` over the defaults swaps a row where it sits, and appends a new one."""
facts = default_facts() | {"Platform": "Pinball", "Docs": "https://example.com"}
assert list(facts) == ["Python", "Platform", "Docs"]
assert facts["Platform"] == "Pinball"
[docs]
def test_env_summary_joins_the_default_facts():
"""The one-line summary says what the version screen's default rows say."""
facts = default_facts()
assert env_summary() == f"Python {facts['Python']}, {facts['Platform']}"
[docs]
def test_screen_declines_when_too_narrow(terminal_width):
screen = VersionScreen(logo=("#" * 20,), facts={"Label": "x" * 40})
terminal_width(40)
assert screen.render("cli", "1.2.3", {}) is None
terminal_width(200)
assert screen.render("cli", "1.2.3", {}) is not None
[docs]
def test_screen_opens_on_a_blank_line_and_never_trails_whitespace(terminal_width):
terminal_width(200)
rendered = VersionScreen(logo=PLAIN_LOGO, tagline="Tagline").render(
"cli", "1.2.3", {}
)
assert rendered is not None
assert rendered.startswith("\n")
for line in rendered.splitlines():
assert line == line.rstrip()
[docs]
def test_screen_seats_every_fact_at_one_column(terminal_width):
"""The facts start at the same column on every line that carries one."""
terminal_width(200)
screen = VersionScreen(logo=PLAIN_LOGO, tagline="Tagline")
drawn = screen.render("cli", "1.2.3", {})
assert drawn is not None
rendered = strip_ansi(drawn)
column = screen.width + len(screen.gutter)
for line in rendered.splitlines():
if len(line) > screen.width:
assert line[screen.width : column].isspace(), f"gutter eaten: {line!r}"
assert not line[column].isspace(), f"fact past the gutter: {line!r}"
[docs]
def test_screen_uses_the_options_own_styles(terminal_width):
"""The header is painted like the plain message, not like something new."""
terminal_width(200)
screen = VersionScreen(logo=PLAIN_LOGO)
rendered = screen.render(
"cli", "1.2.3", {"prog_name": lambda t: f"<{t}>", "version": lambda t: f"[{t}]"}
)
assert rendered is not None
assert "<cli>, version [1.2.3]" in rendered
[docs]
@pytest.fixture
def screen_cli():
@group(
params=partial(default_params, screen=VersionScreen(logo=PLAIN_LOGO)),
version_fields={"version": "1.2.3"},
)
def cli():
echo("hello")
return cli
[docs]
def test_version_option_without_a_screen_is_unchanged(invoke):
"""A CLI that never asks for a screen keeps the one-line message."""
assert VersionOption().screen is None
@group(version_fields={"version": "1.2.3"})
def cli():
echo("hello")
result = invoke(cli, "--version")
assert result.stdout.splitlines() == ["cli, version 1.2.3"]
[docs]
def test_screen_skipped_without_color(invoke, screen_cli):
result = invoke(screen_cli, "--no-color", "--version")
assert result.exit_code == 0
assert not result.stderr
assert result.stdout.splitlines() == ["cli, version 1.2.3"]
[docs]
def test_screen_drawn_with_color(invoke, screen_cli, terminal_width):
terminal_width(200)
with forced_color():
result = invoke(screen_cli, "--color", "--version", color=True)
assert result.exit_code == 0
assert not result.stderr
plain = strip_ansi(result.stdout)
assert plain.startswith("\n")
assert "cli, version 1.2.3" in plain
assert "#####" in plain
[docs]
def test_screen_skipped_when_accessible(invoke, screen_cli, terminal_width):
terminal_width(200)
with forced_color():
result = invoke(screen_cli, "--accessible", "--color", "--version", color=True)
assert result.exit_code == 0
assert "#####" not in strip_ansi(result.stdout)
assert "cli, version 1.2.3" in strip_ansi(result.stdout)
[docs]
@pytest.mark.parametrize(
("theme", "expected"),
(
("dark", "\x1b[97m\x1b[1mcli\x1b[0m, version \x1b[32m1.2.3\x1b[0m"),
("light", "\x1b[30m\x1b[1mcli\x1b[0m, version \x1b[32m1.2.3\x1b[0m"),
("manpage", "\x1b[1mcli\x1b[0m, version 1.2.3"),
),
)
def test_version_message_follows_the_theme(invoke, theme, expected):
"""The message is painted from the active palette, not from a frozen copy.
A palette captured at import would print the program name bright white
under every theme, which on a light terminal is white on white.
"""
@group(version_fields={"version": "1.2.3"})
def cli():
echo("hello")
with forced_color():
result = invoke(cli, "--color", "--theme", theme, "--version", color=True)
assert result.exit_code == 0
assert result.stdout.splitlines() == [expected]
[docs]
@pytest.mark.parametrize(
("color", "stdout_is_a_tty", "expected"),
(
# An explicit decision wins, whatever the stream reports.
(True, False, True),
(True, True, True),
(False, False, False),
(False, True, False),
# `auto` has nothing to go on but the stream.
(None, True, True),
(None, False, False),
),
)
def test_colors_reach_output(monkeypatch, color, stdout_is_a_tty, expected):
"""`auto` reads `sys.stdout`, an explicit tri-state never looks at it.
The stream half is the part worth pinning: `click.echo` reaches stdout
through a wrapper whose public accessor Click deprecates, and this
resolution has to answer what `echo` answers without it.
"""
class Stream(io.StringIO):
def isatty(self) -> bool:
return bool(stdout_is_a_tty)
monkeypatch.setattr("click_extra.version.invocation_color", lambda: color)
monkeypatch.setattr(sys, "stdout", Stream())
# Raising on a deprecation is the half a value assertion cannot cover:
# `click.get_text_stream()` answers the same, and Click deprecates it.
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
assert colors_reach_output() is expected
[docs]
@pytest.mark.once
def test_sphinx_runner_resets_version_resolution():
"""Each documented invocation resolves `--version` for itself.
The resolution chain opens on a stack walk and memoizes, which suits a CLI:
one invocation, one process. A documentation build renders many commands in
one process, and a render carrying no CLI frame (writing roff for a command
tree, say) resolves the chain from a stack the walk cannot read. That answer
would stand for the rest of the build, and a later `--version` example would
publish a screen with no version on it.
This guards the mechanism, not the build: it plants a stale resolution by
hand rather than reproducing the frame shape a real `sphinx-build` has, so
it holds `ClickRunner.invoke` to clearing the memo and says nothing about
which renders would otherwise poison it.
"""
pytest.importorskip("sphinx", reason="click_extra.sphinx needs the sphinx extra")
from click_extra.sphinx.click_runner import ClickRunner
@command
@version_option()
def local_cli():
echo("ran")
option = next(p for p in local_cli.params if isinstance(p, VersionOption))
# The shape a frame-less render leaves behind: a resolved chain naming a
# package that does not exist.
option.__dict__["package_name"] = "not-a-real-package"
ClickRunner().invoke(local_cli, ["--help"], _show_prompt=False)
assert "package_name" not in option.__dict__
[docs]
@pytest.mark.once
def test_reset_resolution_clears_every_memoized_field():
"""`reset_resolution()` drops the whole chain, not a chosen few fields."""
@command
@version_option()
def local_cli():
echo("ran")
option = next(p for p in local_cli.params if isinstance(p, VersionOption))
def memoized():
return {
key
for key in option.__dict__
if isinstance(getattr(type(option), key, None), cached_property)
}
# Resolve the chain, then confirm the reset leaves nothing memoized behind.
assert option.version
assert memoized(), "nothing was memoized, so the test proves nothing"
option.reset_resolution()
assert not memoized(), f"left memoized: {sorted(memoized())}"
[docs]
@pytest.mark.once
def test_reset_resolution_keeps_a_pinned_field():
"""A field pinned through `fields=` is not a resolution, so it survives.
An override lands in the instance dict under the name of the property it
shadows, so a reset that goes by name alone erases it. That silently turns
a pinned version into `None` on every documented invocation.
"""
@command(version_fields={"version": "1.2.3"})
@version_option()
def local_cli():
echo("ran")
option = next(p for p in local_cli.params if isinstance(p, VersionOption))
assert option.version == "1.2.3"
option.reset_resolution()
assert option.version == "1.2.3"
[docs]
def test_deep_copy_keeps_pinned_fields_and_drops_resolved_ones():
"""A copy carries the fields the caller pinned, and resolves the others anew.
`click_extra.multicall` deep-copies the options of a group to build each
personality, which must report the version the group pins.
"""
orchard = types.ModuleType("orchard")
option = VersionOption(fields={"version": "1.2.3", "prog_name": "Orchard"})
option.pin_field("module", orchard)
# Resolved from the pinned module, then memoized.
assert option.module_name == "orchard"
clone = copy.deepcopy(option)
assert clone.version == "1.2.3"
assert clone.prog_name == "Orchard"
# A module is no value `copy.deepcopy` can rebuild: the caller's own one.
assert clone.module is orchard
assert "module_name" not in vars(clone)
assert clone.module_name == "orchard"