Source code for tests.test_help

# 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) ]
[docs] @pytest.mark.once def test_every_preformatted_block_carries_its_own_marker() -> None: """Every indented block in a `--help` text must open with its own marker. Writing one marker above a run of example blocks reads as if it covered them all, and renders the first correctly, which is what lets the rest ship collapsed. Both offenders this caught had exactly that shape. """ offenders = { " ".join(path) or "(root)": blocks for path, cmd in _collect_commands(repomatic) if (blocks := _rewrapped_blocks(cmd.help or "")) } assert not offenders, "Indented --help blocks Click will rewrap: " + "; ".join( f"{name} ({', '.join(blocks)})" for name, blocks in sorted(offenders.items()) )
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}"