# Copyright Kevin Deldycke <[email protected]> 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.
"""Conformance of every command's `--help`: that it is exercised, and readable.
The `--help` invocations themselves live as `[[cases]]` entries in
`tests/cli-test-suite.toml`, executed by `click-extra test-suite` in CI and
against the compiled binaries during releases. A static roster drifts as
commands are added or removed, so the first test compares it against the live
command tree and fails naming the missing or orphaned entries.
Exercising a command's help says nothing about how it reads, which the second
test covers: it walks the same tree and rejects a docstring whose example
blocks Click will rewrap into prose. A third holds every `[tool.repomatic]` key
a help text names to the configuration schema.
"""
from __future__ import annotations
import inspect
import re
from importlib import import_module
from pathlib import Path
import click
import pytest
import tomlrt
from click_extra.testing import CliRunner
from repomatic.cli.main import COMMANDS, ProjectChoice, repomatic
from repomatic.config import config_key_paths
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
SUITE_PATH = Path(__file__).parent / "cli-test-suite.toml"
KEY_SHAPED_WORD = re.compile(r"(?<![\w./$-])[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+(?![\w/-])")
"""A lowercase word joined by dots or hyphens, the two shapes a key name takes."""
FILE_AND_DOMAIN_SUFFIXES = frozenset(("io", "md", "toml", "yaml", "yml"))
"""Last segments that make a dotted word a file or a domain name, never a key."""
NO_REWRAP = "\b"
"""Click's escape suppressing the rewrapping of one paragraph.
It only holds for the paragraph it opens, and only when it is that paragraph's
*first* line: `click.formatting.wrap_text` tests `buf[0]` and nothing else.
"""
def _collect_commands(
group: click.Group | click.Command,
prefix: tuple[str, ...] = (),
) -> list[tuple[tuple[str, ...], click.Command]]:
"""Recursively collect every command path from a Click group, with its command."""
found: list[tuple[tuple[str, ...], click.Command]] = [(prefix, group)]
if isinstance(group, click.Group):
for name in sorted(group.list_commands(click.Context(group))):
cmd = group.get_command(click.Context(group), name)
if cmd is None:
continue
found.extend(_collect_commands(cmd, (*prefix, name)))
return found
def _suite_help_paths() -> set[tuple[str, ...]]:
"""Extract the command path of each `--help` case in the test suite.
A `--help` case is one whose `cli_parameters` end with `--help`; the
words before it are the command path (empty for the root CLI).
"""
suite = tomlrt.loads(SUITE_PATH.read_text(encoding="UTF-8"))
paths: set[tuple[str, ...]] = set()
for case in suite["cases"]:
params = case["cli_parameters"]
if isinstance(params, str):
params = params.split()
if params and params[-1] == "--help":
paths.add(tuple(params[:-1]))
return paths
[docs]
@pytest.mark.once
def test_suite_covers_all_commands() -> None:
"""Every command and subcommand must have a `--help` case in the suite."""
tree = {path for path, _cmd in _collect_commands(repomatic)}
suite_paths = _suite_help_paths()
missing = sorted(tree - suite_paths)
orphans = sorted(suite_paths - tree)
assert not missing, (
"Commands without a --help case in tests/cli-test-suite.toml: "
+ ", ".join(" ".join(path) or "(root)" for path in missing)
)
assert not orphans, (
"--help cases in tests/cli-test-suite.toml without a live command: "
+ ", ".join(" ".join(path) for path in orphans)
)
def _paragraphs(text: str) -> Iterator[list[str]]:
"""Split a docstring into its blank-line-separated blocks of lines."""
block: list[str] = []
for line in text.splitlines():
if line.strip():
block.append(line)
elif block:
yield block
block = []
if block:
yield block
def _rewrapped_blocks(help_text: str) -> list[str]:
"""Indented paragraphs Click will rewrap, for want of their own marker.
An indented block is there to be read as written: an example invocation,
a sample of output. Rewrapping folds it into prose, so a second block
under one `NO_REWRAP` marker loses its line breaks and its command lands
appended to the comment above it.
:param help_text: A command's raw help text.
:return: The first line of each unguarded block, for the failure message.
"""
return [
body[0].strip()
for block in _paragraphs(inspect.cleandoc(help_text))
if block[0].strip() != NO_REWRAP
and (body := [line for line in block if line.strip() != NO_REWRAP])
and all(line.startswith(" ") for line in body)
]
def _wrong_key_names(text: str, keys: frozenset[str]) -> list[str]:
"""Words of *text* that name a `[tool.repomatic]` key the schema lacks.
A dotted word that opens on a table of the schema must be a key, or a path
into the mapping a key holds, like `test-matrix.variations.os`. A last
segment that makes it a file or a domain name, like `gitignore.io`, exempts
it. A hyphenated word that becomes a key when one hyphen turns into a dot
is the flat spelling the nested tables replaced, like `gitignore-location`.
:param text: The help texts of one command.
:param keys: Every dotted key of the schema.
:return: One line per offending word, naming what is wrong with it.
"""
tables = {key.partition(".")[0] for key in keys if "." in key}
wrong = []
for word in sorted(set(KEY_SHAPED_WORD.findall(text))):
table, dot, rest = word.partition(".")
segments = word.split(".")
prefixes = {".".join(segments[:end]) for end in range(2, len(segments) + 1)}
if (
dot
and table in tables
and not prefixes & keys
and rest.rpartition(".")[2] not in FILE_AND_DOMAIN_SUFFIXES
):
wrong.append(f"{word} is no key")
for index, char in enumerate(word):
dotted = f"{word[:index]}.{word[index + 1 :]}"
if char == "-" and dotted in keys:
wrong.append(f"{word} is the flat spelling of {dotted}")
return wrong
[docs]
@pytest.mark.once
def test_every_help_text_names_real_config_keys() -> None:
"""Every `[tool.repomatic]` key a `--help` text names exists.
A key renamed or nested in the schema keeps its old name in prose nobody
reruns: `sync-gitignore` sent readers to `gitignore-extra-content` long after
the key became `gitignore.extra-content`.
"""
keys = frozenset(config_key_paths())
offenders = {
" ".join(path) or "(root)": wrong
for path, cmd in _collect_commands(repomatic)
if (
wrong := _wrong_key_names(
"\n".join((
cmd.help or "",
cmd.short_help or "",
*(getattr(param, "help", None) or "" for param in cmd.params),
)),
keys,
)
)
}
assert not offenders, "Help texts naming no real key: " + "; ".join(
f"{name} ({', '.join(wrong)})" for name, wrong in sorted(offenders.items())
)
def _lazy_choice_types() -> list[type[ProjectChoice]]:
"""Every concrete `ProjectChoice` in the package, for the checks below.
A subclass registers only once its module runs, and the group imports a
command module only when a run needs it, so every one is imported first.
"""
for spec in COMMANDS.values():
import_module(spec.import_path.rpartition(".")[0])
return list(ProjectChoice.__subclasses__())
[docs]
@pytest.mark.parametrize("choice_type", _lazy_choice_types(), ids=lambda c: c.__name__)
def test_lazy_choice_degrades_instead_of_refusing_everything(choice_type, monkeypatch):
"""A type that cannot resolve accepts anything, rather than nothing.
An empty `Choice` refuses every value including the option's own default,
so a project the type cannot read would take the whole command down. Each
subclass either declares a `fallback` it can always offer, or passes the
value through untouched.
"""
monkeypatch.setattr(choice_type, "resolve", lambda self: (), raising=True)
instance = choice_type()
assert instance.choices == choice_type.fallback
if not instance.choices:
# No values means "cannot tell": convert passes through, and the
# metavar falls back to the plain name rather than an empty `[]`.
assert instance.convert("papaya", None, None) == "papaya"
assert instance.get_metavar(None, None) is None
[docs]
@pytest.mark.parametrize("choice_type", _lazy_choice_types(), ids=lambda c: c.__name__)
def test_lazy_choice_survives_a_resolver_that_raises(choice_type, monkeypatch):
"""Resolution runs while Click renders, where a traceback replaces the help."""
def boom(self):
raise RuntimeError("papaya")
monkeypatch.setattr(choice_type, "resolve", boom, raising=True)
assert choice_type().choices == choice_type.fallback
[docs]
@pytest.mark.once
def test_every_command_renders_its_help_outside_a_project(tmp_path, monkeypatch):
"""`--help` must render anywhere, whatever the lazy types can resolve.
Every one of them reads the working directory, so a directory holding no
`pyproject.toml` and no `.github/` is where an unguarded resolver shows up.
"""
monkeypatch.chdir(tmp_path)
for path, _cmd in _collect_commands(repomatic):
result = CliRunner().invoke(repomatic, [*path, "--help"])
assert result.exit_code == 0, f"{' '.join(path)}: {result.output}"