# 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.
.. todo::
Test standalone scripts setting package name to filename and version to
`None`.
.. todo::
Test standalone script fetching version from ``__version__`` variable.
"""
from __future__ import annotations
import inspect
import re
import sys
import click
import pytest
from boltons.strutils import strip_ansi
from click_extra import (
ExtraVersionOption,
Style,
__version__,
color_option,
command,
echo,
group,
pass_context,
verbosity_option,
version_option,
)
from click_extra.commands import default_extra_params
from click_extra.pytest import (
command_decorators,
default_debug_colored_log_end,
default_debug_colored_logging,
default_debug_colored_version_details,
)
from .conftest import skip_windows_colors
# Regex matching the version with optional PEP 440 local version suffix for dev
# versions (e.g., "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\[97mstandalone-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\[97mdebug-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(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[97mcolor-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
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
@pytest.mark.parametrize(
"message, regex_stdout",
(
(
"{prog_name}, version {version}",
r"\x1b\[97mcolor-cli3\x1b\[0m, "
rf"version \x1b\[32m{_ver}"
r"\x1b\[0m\n",
),
(
"{prog_name}, version {version}\n{env_info}",
r"\x1b\[97mcolor-cli3\x1b\[0m, "
rf"version \x1b\[32m{_ver}"
r"\x1b\[0m\n"
r"\x1b\[90m{'.+'}"
r"\x1b\[0m\n",
),
(
"{prog_name} v{version} - {package_name}",
r"\x1b\[97mcolor-cli3\x1b\[0m "
rf"v\x1b\[32m{_ver}"
r"\x1b\[0m - "
r"\x1b\[97mclick_extra"
r"\x1b\[0m\n",
),
(
"{prog_name}, version {version} (Python {env_info[python][version]})",
r"\x1b\[97mcolor-cli3\x1b\[0m, "
rf"version \x1b\[32m{_ver}\x1b\[0m "
r"\(Python \x1b\[90m3\.\d+\.\d+.+\x1b\[0m\)\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,
version_style=None,
prog_name_style=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"),
prog_name_style=Style(fg="green", bold=True),
version_style=Style(fg="bright_yellow", bg="red"),
package_name_style=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.parametrize("cmd_decorator", command_decorators(no_groups=True))
def test_context_meta(invoke, cmd_decorator, assert_output_regex):
@cmd_decorator
@version_option
@pass_context
def version_metadata(ctx):
for field in ExtraVersionOption.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"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}\n"
r"git_short_hash = [a-f0-9]{4,40}\n"
r"git_date = \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4}\n"
r"prog_name = version-metadata\n"
r"env_info = {'.+'}\n"
),
)
assert result.output == strip_ansi(result.output)
assert not result.stderr
assert result.exit_code == 0
[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
``env_info`` or git fields.
"""
@cmd_decorator
@version_option(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 ExtraVersionOption instance from the command.
version_param = next(
p for p in lazy_cli.params if isinstance(p, ExtraVersionOption)
)
# Fields that were never accessed should NOT have been cached.
assert "env_info" not in version_param.__dict__
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.
"""
import types
# Create a fake parent package with __version__.
fake_parent = types.ModuleType("myapp")
fake_parent.__version__ = "1.2.3"
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 = ExtraVersionOption(["--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]
def test_cli_frame_fallback(monkeypatch):
"""``cli_frame()`` falls back to the outermost frame when all frames are
from the Click ecosystem."""
original_stack = inspect.stack
def patched_stack():
"""Make every frame look like it belongs to click_extra."""
frames = original_stack()
for frame_info in frames:
frame_info.frame.f_globals.setdefault("__name__", "")
# Temporarily override __name__ so the heuristic skips all frames.
frame_info.frame.f_globals["__name__"] = (
"click_extra." + frame_info.function
)
return frames
monkeypatch.setattr(inspect, "stack", patched_stack)
# Should not raise RuntimeError; instead falls back to outermost frame.
frame = ExtraVersionOption.cli_frame()
assert frame is not None
[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_extra_params()
for p in params:
if isinstance(p, ExtraVersionOption):
p.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[97mcolor-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 ``ExtraVersionOption``."""
@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[97mMy 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[97mmy-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[97mMy Group\x1b[0m, version" in result.output
assert result.exit_code == 0
result = invoke(prog_name_grp, "--help", color=True)
assert "\x1b[97mmy-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]
@skip_windows_colors
def test_color_option_precedence(invoke):
"""--no-color has an effect on --version, if placed in the right order.
Eager parameters are evaluated in the order as they were provided on the command
line by the user as expleined in:
https://click.palletsprojects.com/en/stable/click-concepts/#callback-evaluation-order
.. todo::
Maybe have the possibility to tweak CLI callback evaluation order so we can
let the user to have the NO_COLOR env set to allow for color-less ``--version``
output.
"""
@click.command
@color_option
@version_option(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[97mcolor-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(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(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(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_dev_version(init_file):
"""A ``.dev`` version gets ``+hash`` appended in the file."""
p = init_file('__version__ = "1.0.0.dev0"\n')
result = ExtraVersionOption.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 = ExtraVersionOption.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 = ExtraVersionOption.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 = ExtraVersionOption.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 = ExtraVersionOption.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):
ExtraVersionOption.prebake_version(p)
[docs]
def test_prebake_idempotent(init_file):
"""Running prebake twice does not double-suffix."""
p = init_file('__version__ = "1.0.0.dev0"\n')
first = ExtraVersionOption.prebake_version(p, local_version="abc1234")
assert first == "1.0.0.dev0+abc1234"
second = ExtraVersionOption.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)
ExtraVersionOption.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
def __test_inplace_context():
@click.command
@version_option
def cli():
pass
with cli.make_context("foo", []) as ctx:
for field in ExtraVersionOption.template_fields:
value = ctx.meta[f"click_extra.{field}"]
assert value is not None