Source code for tests.test_docs

# 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.

from __future__ import annotations

import ast
import csv
import functools
import importlib.util
import re
import shutil
from datetime import UTC, date, datetime
from itertools import permutations
from pathlib import Path
from textwrap import indent
from urllib.parse import quote, urlparse

import pytest
from click_extra import KO_GLYPH
from extra_platforms import Group, extract_members
from wcwidth import wcswidth
from yaml import Loader, load, safe_load

from meta_package_manager import __version__, _docs, definitions, logo
from meta_package_manager.capabilities import Operations
from meta_package_manager.cli import mpm
from meta_package_manager.dispatch import (
    COMMAND_FAN_OUT,
    FAN_OUT_CONCURRENT,
    FAN_OUT_GROUPED,
    FAN_OUT_NONE,
    FAN_OUT_SEQUENTIAL,
    SHARED_LOCK_FAMILIES,
)
from meta_package_manager.docstring_corpus import literal_blocks
from meta_package_manager.labels import (
    LABELS,
    MANAGER_LABEL_COLOR,
    MANAGER_LABELS,
    MANAGER_PREFIX,
    PLATFORM_PREFIX,
    generate_content_rules,
    generate_file_rules,
)
from meta_package_manager.platforms import MAIN_PLATFORMS
from meta_package_manager.pool import pool
from meta_package_manager.tables import PackageOutcome

from .conftest import PROJECT_ROOT, all_managers, tomllib


@functools.cache
def _load_docs_update():
    """Load `docs/docs_update.py` as a module without requiring `docs` to be a package.

    The script lives next to the docs but is not part of any importable
    package, so we resolve it by file path.
    """
    spec = importlib.util.spec_from_file_location(
        "docs_update",
        Path(__file__).parent.parent / "docs" / "docs_update.py",
    )
    module = importlib.util.module_from_spec(spec)  # type: ignore[arg-type]
    spec.loader.exec_module(module)  # type: ignore[union-attr]
    return module


[docs] @pytest.fixture def docs_update(): """The `docs/docs_update.py` module, or a skip when `tomlrt` is missing. The script edits `pyproject.toml` with `tomlrt`, which most distributions do not package. The skip keeps the hermetic layer free of it. """ pytest.importorskip( "tomlrt", reason="tomlrt is optional; hermetic builds run without it", ) return _load_docs_update()
""" Test all non-code artifacts depending on manager definitions. Covers: * Documentation (sphinx, readme, etc.) * CI/CD scripts * GitHub project config These tests are mainly there to remind us keep extra stuff in sync on new platform or manager addition. """
[docs] def test_unique_local_group_ids(): """Check our platform groups have unique identifiers.""" group_ids = [p.id for p in MAIN_PLATFORMS] assert len(group_ids) == len(set(group_ids))
[docs] def test_local_groups_no_overlap(): """Check our platform groups are mutually exclusive.""" for a, b in permutations(MAIN_PLATFORMS, 2): if isinstance(a, Group): assert a.isdisjoint(b)
[docs] @all_managers def test_all_platforms_covered_by_local_groups(manager): """Check all platforms supported by managers are covered by a local group.""" leftover_platforms = set(manager.platforms.copy()) for main_platform in (set(extract_members(i)) for i in MAIN_PLATFORMS): leftover_platforms -= main_platform assert len(leftover_platforms) == 0
# At this stage we know all platforms of the manager can be partitioned by a # combination of MAIN_PLATFORMS elements, without any overlap or leftover.
[docs] def test_project_metadata(): # Fetch general information about the project from pyproject.toml. toml_path = PROJECT_ROOT.joinpath("pyproject.toml").resolve() toml_config = tomllib.loads(toml_path.read_text(encoding="utf-8")) # Check all managers are referenced in Python package keywords. assert set(pool.all_manager_ids).issubset(toml_config["project"]["keywords"])
[docs] def test_docs_deploy_where_infrastructure_says(): """Check the docs workflow deploys where the infrastructure record says. `docs/infrastructure.md` names `[tool.repomatic] site.deploy` as the switch selecting the Cloudflare Pages jobs of the shared docs workflow. A key unset or repointed keeps publishing to the GitHub Pages site the domain no longer serves from, silently: both hosts answer, only one is read. The record and the key were split for two days once; this holds them together. """ config = tomllib.loads( (PROJECT_ROOT / "pyproject.toml").read_text(encoding="UTF-8") ) assert config["tool"]["repomatic"]["site"]["deploy"] == "cloudflare-pages"
[docs] def test_docs_site_url_matches_pyproject(): """The canonical docs origin is declared once, in `pyproject.toml`. `docs/conf.py` reads it from there for `html_baseurl`, but runtime code cannot: `pyproject.toml` is not shipped in the wheel, so `DOCS_SITE_URL` repeats the literal. A drift would point the readme's manager links at a different origin than the canonical tags, which is exactly the split-indexing the custom domain exists to avoid. """ toml_path = PROJECT_ROOT.joinpath("pyproject.toml").resolve() toml_config = tomllib.loads(toml_path.read_text(encoding="utf-8")) declared = toml_config["project"]["urls"]["Documentation"] assert _docs.DOCS_SITE_URL == declared.rstrip("/") # Consumers append rooted paths, so a trailing slash would double the # separator on every generated link. assert not _docs.DOCS_SITE_URL.endswith("/")
[docs] def test_redirects_file_is_well_formed(): """`docs/_redirects` sends every retired URL to a page that still exists. Two of these invariants are the Cloudflare Pages engine's own. A rule only counts as static while it appears before the first rule holding a `*` or a `:placeholder`; from there on every line spends the 100-rule dynamic budget, and the parser discards the rest of the file at the 101st rather than reporting anything. The third is this project's: a redirect landing on a page that no longer exists just trades a 404 for a slower one, and a rule whose source is a page the site still publishes hides that page entirely. """ redirects = PROJECT_ROOT.joinpath("docs", "_redirects") rules = [] for line in redirects.read_text(encoding="UTF-8").splitlines(): if not (stripped := line.strip()) or stripped.startswith("#"): continue source, destination, *status = stripped.split() # A retired URL is retired for good: nothing here is a temporary move. assert status == ["301"], f"{source} is not a permanent redirect." rules.append((source, destination)) def is_dynamic(url: str) -> bool: return "*" in url or ":" in url first_dynamic = next( (index for index, (source, _) in enumerate(rules) if is_dynamic(source)), len(rules), ) assert all(is_dynamic(source) for source, _ in rules[first_dynamic:]), ( "An exact rule sits after a dynamic one, so it spends the dynamic budget." ) assert len(rules) - first_dynamic < 100 # Every page Sphinx publishes, as the site path the dirhtml builder gives it. docs = PROJECT_ROOT.joinpath("docs") pages = { "/" if path.stem == "index" else f"/{path.stem}/" for path in docs.glob("*.md") } pages.update( f"/managers/{path.stem}/" for path in docs.joinpath("managers").glob("*.md") ) for source, destination in rules: if not is_dynamic(destination): assert destination in pages, ( f"{source} redirects to {destination}, which is not a page." ) if not is_dynamic(source): retired = source.removesuffix(".html").rstrip("/") + "/" assert retired not in pages, ( f"{source} shadows {retired}, a page the site still publishes." )
[docs] def test_changelog(): """Check every changelog bullet opens with a known `[scope]` tag. The tags are sorted, deduplicated and drawn from the pool manager IDs, the platform IDs and the `mpm`, `bar-plugin` and `gnome-shell` scopes. The vocabulary is not cosmetic: `scope_changelog()` indexes it to build the release history of each manager and frontend page, and its docstring holds the rules to choose a tag. """ content = PROJECT_ROOT.joinpath("changelog.md").read_text(encoding="utf-8") assert content.startswith("# Changelog\n") entry_pattern = re.compile( r"^- (?:\*\*[A-Za-z]+:\*\* )?\[(?P<category>[a-z0-9,\-]+)\] (?P<entry>.+)" ) allowed_categories = { *pool.all_manager_ids, *(p.id for p in MAIN_PLATFORMS), "mpm", "bar-plugin", "gnome-shell", } for line in content.splitlines(): if line.startswith("-"): match = entry_pattern.match(line) assert match entry = match.groupdict() assert entry["category"] categories = entry["category"].split(",") assert len(categories) assert len(categories) == len(set(categories)) assert categories == sorted(categories) assert set(categories).issubset(allowed_categories)
[docs] def test_labels(): for name, color, description in LABELS: assert name assert color assert color.startswith("#") assert len(description) <= 100
[docs] @pytest.mark.repo_maintenance def test_new_package_manager_issue_template(): """Check all platforms groups are referenced in the issue template. Repo-maintenance guard: the reference set is regenerated from the installed `extra_platforms` release, whose platform groups a downstream packager cannot be expected to match, so `conftest` skips it outside a git checkout. It also reads `.github/`, absent from a wheel install. """ content = PROJECT_ROOT.joinpath( ".github/ISSUE_TEMPLATE/new-package-manager.yml", ).read_text(encoding="utf-8") assert content template_platforms = load(content, Loader=Loader)["body"][3]["attributes"][ "options" ] reference_labels = [] for p_obj in MAIN_PLATFORMS: label = f"{p_obj.icon} {p_obj.name}" if isinstance(p_obj, Group) and len(p_obj) > 1: members = p_obj.members.values() # type: ignore[attr-defined] label += f" ({', '.join(p.name for p in members)})" reference_labels.append({"label": label}) assert template_platforms == reference_labels
[docs] @pytest.mark.repo_maintenance def test_extra_labels_in_pyproject(): """Check the generated label block is consistent with `LABELS`. The `[tool.repomatic.labels.extra]` block of `pyproject.toml`. Repo-maintenance guard: the platform labels are regenerated from the installed `extra_platforms` release, whose platform groups a downstream packager cannot be expected to match, so `conftest` skips it outside a git checkout. """ pyproject = PROJECT_ROOT / "pyproject.toml" data = tomllib.loads(pyproject.read_text(encoding="utf-8")) extra_labels = data["tool"]["repomatic"]["labels"]["extra"] assert extra_labels names = [lbl["name"] for lbl in extra_labels] # Labels are unique. assert len(names) == len(set(names)) canonical_labels = set(names) # Contains both manager and platform labels. canonical_managers = { lbl for lbl in canonical_labels if lbl.startswith(MANAGER_PREFIX) and "mpm" not in lbl } assert canonical_managers canonical_platforms = { lbl for lbl in canonical_labels if lbl.startswith(PLATFORM_PREFIX) } assert canonical_platforms # The block matches the in-memory LABELS registry. Colors are stored # without the leading '#', following the labelmaker/repomatic convention. registry = {(name, color.lstrip("#"), desc) for name, color, desc in LABELS} generated = { (lbl["name"], lbl["color"], lbl["description"]) for lbl in extra_labels } assert generated == registry
[docs] def test_label_rules_reference_known_labels(): """Every file- and content-rule targets a label that exists. The label must be in the generated `[tool.repomatic.labels.extra]` block. A rule naming an unknown label silently applies a label the repository does not have: the stale `dnf-based` rules left behind when the `labels.py` group was renamed `rpm-based`, or per-manager rules (`zypper`, `pacstall`) outliving the manager's absorption into an ecosystem group. """ pyproject = PROJECT_ROOT / "pyproject.toml" data = tomllib.loads(pyproject.read_text(encoding="utf-8")) labels_config = data["tool"]["repomatic"]["labels"] known = {label["name"] for label in labels_config["extra"]} for section in ("content-rules", "file-rules"): stale = [label for label in labels_config[section] if label not in known] assert not stale, f"{section} reference unknown labels: {stale}"
[docs] def test_label_rules_in_pyproject(): """Check the generated labeller rule blocks match a fresh generation. The `[tool.repomatic.labels.*]` blocks of `pyproject.toml` are generated from the pool, in the schema repomatic reads. Drift means a manager was added without running `docs/docs_update.py` (repomatic's `update-docs` job self-heals this on the next push). The shape is asserted first, and separately from the content, because the two fail for unrelated reasons. repomatic `7.11.0` replaced the array-of-tables form with a label-to-patterns table and ignores the old one with a warning, which dropped all 130 of these rules without failing anything: the generator and the file still agreed with each other, in a schema the consumer had abandoned. mpm does not depend on repomatic, so the shape is all this suite can check on its own. """ pyproject = PROJECT_ROOT / "pyproject.toml" data = tomllib.loads(pyproject.read_text(encoding="utf-8")) labels_config = data["tool"]["repomatic"]["labels"] for key in ("content-rules", "file-rules"): rules = labels_config[key] assert isinstance(rules, dict), ( f"[tool.repomatic.labels.{key}] must be a table mapping each label to " f"its patterns, not {type(rules).__name__}. An array-of-tables is the " "pre-7.11.0 form, which repomatic ignores with a warning." ) assert all( isinstance(patterns, list) and patterns for patterns in rules.values() ), f"Every [tool.repomatic.labels.{key}] label needs a non-empty pattern list." checked_in_content = [ (label, tuple(patterns)) for label, patterns in labels_config["content-rules"].items() ] assert checked_in_content == generate_content_rules() checked_in_file = [ (label, tuple(globs)) for label, globs in labels_config["file-rules"].items() ] assert checked_in_file == generate_file_rules()
[docs] def test_pyproject_updates_are_pyproject_fmt_fixpoint( docs_update, monkeypatch, tmp_path ): """The `pyproject.toml` writers must emit `pyproject-fmt`-canonical style. Any deviation makes the `update-docs` and `format-pyproject` autofix jobs endlessly rewrite each other's output. The committed file is canonicalized first, so the assertion only judges the writers' own output, whatever formatting state the working tree is in. """ pyproject_fmt = pytest.importorskip( "pyproject_fmt", reason="pyproject-fmt is optional; hermetic builds run without it", ) scratch = tmp_path / "pyproject.toml" shutil.copyfile(PROJECT_ROOT / "pyproject.toml", scratch) pyproject_fmt.run([str(scratch)]) monkeypatch.setattr(docs_update, "PROJECT_ROOT", tmp_path) docs_update.update_labels() docs_update.update_keywords() docs_update.update_metrics_subjects() assert pyproject_fmt.run(["--check", str(scratch)]) == 0
[docs] @pytest.mark.parametrize( "glyph", sorted({ label[0] for rules in (generate_content_rules(), generate_file_rules()) for label, _ in rules }), ) @pytest.mark.parametrize("columns", (119, 120, 121)) def test_string_array_wraps_where_pyproject_fmt_wraps( docs_update, glyph, columns, tmp_path ): """`_string_array` must reach `pyproject-fmt`'s own inline-or-exploded verdict. The fixpoint test above only measures the arrays the pool happens to generate today, so it stays green while the rule is wrong and no line sits near the limit: a budget counting the array alone, ignoring its key, passed for years until one label reached the boundary. This drives the boundary directly, for every glyph a label opens on. Width, not length: `pyproject-fmt` 2.29 began counting a wide glyph as two columns, so a key carrying one loses a column of budget that character arithmetic cannot see. The array stays deliberately short and the *key* carries the padding, which is the only shape that separates the two rules: an array long enough to breach a budget of its own is exploded either way, and agreement there proves nothing. Every label this project generates is exactly that shape, a two-path array under a key half the line wide. """ pyproject_fmt = pytest.importorskip( "pyproject_fmt", reason="pyproject-fmt is optional; hermetic builds run without it", ) # Two short paths, mirroring a real file rule and well inside any budget an # array alone could carry. values = ("a" * 30, "b" * 30) inline = '[ "' + '", "'.join(values) + '" ]' # Pad the label name so the whole rendered line measures `columns` wide. padding = columns - wcswidth(f'labels.file-rules."{glyph} " = {inline}') assert padding > 0, "glyph too wide to probe this column count" key = f'labels.file-rules."{glyph} {"n" * padding}"' line = f"{key} = {docs_update._string_array(values, key=key)}" assert wcswidth(line.splitlines()[0]) <= docs_update.COLUMN_WIDTH # Only the generated section is judged: `pyproject-fmt` also normalizes # `[project]`, whose churn would answer for the array being measured. scratch = tmp_path / "pyproject.toml" scratch.write_text( f'[project]\nname = "x"\nversion = "1"\n\n[tool.repomatic]\n{line}\n', encoding="UTF-8", ) pyproject_fmt.run([str(scratch)]) section = scratch.read_text(encoding="UTF-8").partition("[tool.repomatic]\n")[2] assert section.strip() == line.strip()
[docs] def test_benchmark_toml_well_formed(): """Check `docs/benchmark.toml` encodes only known flags and homepages. Flags from the known competitor set, and homepage URLs for non-pool managers. A duplicate manager-id row anywhere in the file (in any table) is a `tomllib.TOMLDecodeError` at load time, below, rather than a silently overwritten value: no separate assertion is needed to hold that invariant. """ toml_path = PROJECT_ROOT / "docs" / "benchmark.toml" data = tomllib.loads(toml_path.read_text(encoding="UTF-8")) assert set(data) == { "managers", "homepages", "coarse_support", "refused", "unsupported", "queued", } competitors = set(_docs.BENCHMARK_COMPETITORS) for mid, flags in data["managers"].items(): assert mid == mid.lower() assert isinstance(flags, list) # Flags are valid competitor names, unique, and sorted in column order. assert set(flags).issubset(competitors) assert len(flags) == len(set(flags)) assert flags == sorted(flags, key=_docs.BENCHMARK_COMPETITORS.index) # Homepages mapping: lowercase IDs pointing to HTTPS URLs, sorted # alphabetically. homepages = data["homepages"] assert list(homepages) == sorted(homepages), ( "homepages keys must be sorted alphabetically" ) for mid, url in homepages.items(): assert mid == mid.lower() assert isinstance(url, str) assert url.startswith(("http://", "https://")) # coarse_support: mapping ``{manager_id: {competitor: url}}``. Every # listed (mid, competitor) pair must also exist in managers (you cannot # be coarse-only-supported without being supported at all). coarse = data["coarse_support"] assert list(coarse) == sorted(coarse), ( "coarse_support keys must be sorted alphabetically" ) for mid, entries in coarse.items(): assert mid == mid.lower() assert isinstance(entries, dict) assert entries, f"{mid!r} has an empty coarse_support mapping; omit the row" assert list(entries) == sorted(entries), ( f"coarse_support[{mid!r}] competitor keys must be sorted alphabetically" ) assert set(entries).issubset(competitors) # No-orphan invariant: the manager must be supported in the first place. assert mid in data["managers"], ( f"coarse_support[{mid!r}] has no matching entry in managers" ) missing = set(entries) - set(data["managers"][mid]) assert not missing, ( f"coarse_support[{mid!r}] flags competitors {sorted(missing)} that " f"are not in managers[{mid!r}]" ) for competitor, url in entries.items(): assert isinstance(url, str) assert url.startswith(("http://", "https://")), ( f"coarse_support[{mid!r}][{competitor!r}] URL must be an http(s) link" ) # refused: mapping ``{manager_id: {competitor: evidence_url}}``. Each # (manager_id, competitor) pair must NOT overlap with managers[mid]: # a competitor cannot both support and refuse the same manager. refused = data["refused"] assert list(refused) == sorted(refused), ( "refused keys must be sorted alphabetically" ) for mid, entries in refused.items(): assert mid == mid.lower() assert isinstance(entries, dict) assert entries, f"{mid!r} has an empty refused mapping; omit the row" assert list(entries) == sorted(entries), ( f"refused[{mid!r}] competitor keys must be sorted alphabetically" ) assert set(entries).issubset(competitors) # No-conflict invariant: a competitor cannot be listed in both # managers (supports) and refused (declined) for the same mid. supports = set(data["managers"].get(mid, [])) conflict = supports & set(entries) assert not conflict, ( f"refused[{mid!r}] lists competitors {sorted(conflict)} that also " f"support the manager in managers[{mid!r}]" ) for competitor, url in entries.items(): assert isinstance(url, str) assert url.startswith(("http://", "https://")), ( f"refused[{mid!r}][{competitor!r}] URL must be an http(s) link" ) # unsupported: ``{manager_id: status}`` for managers mpm deliberately does # not wrap. Their mpm cell renders the status glyph linked to the recorded # decision, instead of a blank cell. unsupported = data["unsupported"] assert isinstance(unsupported, dict) assert list(unsupported) == sorted(unsupported), ( "unsupported keys must be sorted alphabetically" ) pool_ids = set(pool.all_manager_ids) for mid, status in unsupported.items(): assert mid == mid.lower() # Each status must map to a glyph the generator knows how to render. assert status in _docs.UNSUPPORTED_GLYPHS, ( f"unsupported[{mid!r}] has unknown status {status!r}; " f"expected one of {sorted(_docs.UNSUPPORTED_GLYPHS)}" ) # No-orphan invariant: the row must exist for the cell to render into. assert mid in data["managers"], ( f"unsupported[{mid!r}] has no matching entry in managers" ) # No-contradiction invariant: a wrapped manager cannot be refused. assert mid not in pool_ids, ( f"unsupported lists {mid!r}, which mpm actually wraps" )
[docs] def test_benchmark_homepages_cover_non_pool_managers(): """Every non-pool manager of `benchmark.toml` has a `homepages` entry. So the table can link the identifier. Pool-implemented managers are excluded: their URL is sourced from the class's `homepage_url` attribute, and a redundant entry in the TOML would create two sources of truth. A tool declined for a dead upstream is exempt, its site being possibly gone too. A Wikipedia article never stands in for the missing home page: it has a slot of its own, so a home page found later is added rather than swapped in. """ toml_path = PROJECT_ROOT / "docs" / "benchmark.toml" data = tomllib.loads(toml_path.read_text(encoding="UTF-8")) pool_ids = set(pool.all_manager_ids) benchmark_ids = set(data["managers"]) homepage_ids = set(data["homepages"]) # Every non-pool TOML manager must have a homepage URL, unless archived. archived = {mid for mid, s in data["unsupported"].items() if s == "archived"} missing = (benchmark_ids - pool_ids - archived) - homepage_ids assert not missing, f"Missing homepage URLs in benchmark.toml: {sorted(missing)}" # Homepages must not duplicate pool managers (those come from the class). overlap = homepage_ids & pool_ids assert not overlap, f"Pool managers must not appear in homepages: {sorted(overlap)}" # Homepages must not include unknown manager IDs. extra = homepage_ids - benchmark_ids assert not extra, f"Unknown manager IDs in homepages: {sorted(extra)}" articles = sorted(m for m, u in data["homepages"].items() if "wikipedia.org" in u) assert not articles, f"Wikipedia articles used as home pages: {articles}"
[docs] @all_managers def test_manager_homepage_url(manager): """Every pool manager links its upstream, by home page or by repository. A malformed URL breaks the rendered card, and a Wikipedia article has a slot of its own. """ assert manager.homepage_url or manager.repository_url if manager.homepage_url is None: return assert isinstance(manager.homepage_url, str) assert manager.homepage_url.startswith(("http://", "https://")) assert "wikipedia.org" not in manager.homepage_url, "use `wikipedia_url`"
WIKIPEDIA_URL = "https://en.wikipedia.org/wiki/"
[docs] def check_wikipedia_url(url: str) -> None: """Check a URL names an English Wikipedia article the way its address bar does. MediaWiki answers a title spelled any other way with a permanent redirect, which the link checker then reports: a space instead of an underscore, or a lowercase first letter where the software capitalizes it. """ assert url.startswith(WIKIPEDIA_URL), f"{url} is no English Wikipedia article" title = url.removeprefix(WIKIPEDIA_URL) assert title, f"{url} names no article" assert " " not in title, f"{url} must spell spaces as underscores" assert not title[0].islower(), f"{url} must capitalize the title's first letter"
[docs] @all_managers def test_manager_wikipedia_url(manager): """Check a manager's Wikipedia article is an English one, and its own. The metaclass resets the attribute on every class not declaring it, so a subclass never shows its parent's article: `paru` extends `pacman` in code, but is a project of its own. """ assert "wikipedia_url" in vars(type(manager)), f"{manager.id} inherits it" url = manager.wikipedia_url if url is None: return check_wikipedia_url(url)
[docs] def test_benchmark_table_renders(): """Check the `Package manager support` table generator still works. It must produce a well-formed table from the current pool and TOML. The table is rendered live at Sphinx build time by the ``{python:render}`` block in `docs/benchmark.md`, and `test_mirror_blocks_in_sync` compares its checked-in copy: this test only guards the generator against crashes and structural regressions, like a broken TOML entry. """ table = _docs.benchmark_managers_table() lines = table.splitlines() assert len(lines) > 2 header = lines[0] assert header.startswith("| Manager") assert "`mpm`" in header for competitor in _docs.BENCHMARK_COMPETITORS: assert f"`{competitor}`" in header # Every pool manager lands one row, its identifier and its glyph both # linking to its dedicated documentation page. A wrapped manager shows ✅, # or âš ī¸ when its upstream is gone: the two partition the pool, so together # they must account for every manager exactly once. unmaintained = sum(1 for m in pool.values() if m.unmaintained) assert unmaintained, "expected at least one unmaintained manager in the pool" assert sum(line.count("[✅](") for line in lines) == len(pool) - unmaintained assert sum(line.count("[âš ī¸](") for line in lines) == unmaintained assert sum(line.count("](managers/") for line in lines) == 2 * len(pool) rows = {line.split("|")[1].strip(): line for line in lines[2:]} for mid, manager in pool.items(): page = f"managers/{mid}.md" glyph = "âš ī¸" if manager.unmaintained else "✅" assert f"[{glyph}]({page})" in rows[f"[`{mid}`]({page})"] # â˜ ī¸ marks what mpm never wrapped, so it must never land on a pool manager. assert "[â˜ ī¸](managers/" not in table
def _metrics_config() -> dict: """The `[tool.repomatic.metrics]` table, through the docs generators' reader.""" return _docs._metrics_config() def _canonical_repo_url(target: str) -> str: """Mirror the sampler's rule: a bare slug is GitHub, anything else a URL.""" return target if "://" in target else f"https://github.com/{target}" def _load_metrics_store() -> list[dict]: """The committed metrics store, as parsed rows.""" with _docs.METRICS_STORE.open(encoding="UTF-8") as store: return list(csv.DictReader(store))
[docs] def test_manager_upstreams_cover_the_pool(docs_update): """Check every wrapped manager is either measured upstream, or excused. The metrics subjects and the excuse map partition the pool, so a manager added without a decision on its upstream fails here instead of quietly rendering empty cells. Both are also checked against the pool from the other side: an ID left behind by a renamed or dropped manager would otherwise sample forever. """ subjects = _metrics_config()["subjects"] measured = {name for name in subjects if name in pool} excused = set(_docs.NO_UPSTREAM) assert measured | excused == set(pool) assert not measured & excused forges = set(docs_update.SAMPLED_FORGES) | set(_metrics_config()["forges"]) for manager_id in measured: url = _canonical_repo_url(subjects[manager_id]) assert url.startswith("https://"), f"{manager_id} needs an https URL" host, _, path = url.removeprefix("https://").partition("/") # An unknown host would raise mid-sample, one manager at a time. assert host in forges, f"{manager_id} sits on {host}" owner, _, name = path.partition("/") assert owner and name, f"{manager_id} needs an owner/name path, got {path!r}" # Every reason reads as a sentence, since it is the only record of why a # manager shows nothing. for reason in _docs.NO_UPSTREAM.values(): assert reason.endswith(".")
[docs] @pytest.mark.repo_maintenance def test_metrics_subjects_in_pyproject(docs_update): """Check the manager entries of the metrics subjects follow `repository_url`. Drift means a manager's repository changed without running `docs/docs_update.py`, and the weekly sample would go on reading the old one. Repo-maintenance guard: it rewrites `pyproject.toml` from the pool and compares, and `uv_build` normalizes that file into the sdist, dropping the one-line-per-subject shape the comparison reads. So the check is void outside a git checkout, where `conftest` skips it. A mirror stands in only for a repository the sampler cannot read, and must itself be readable, or it replaces a subject for nothing. """ assert not docs_update.update_metrics_subjects(check=True) forges = set(docs_update.SAMPLED_FORGES) | set(_metrics_config()["forges"]) for manager_id, mirror in docs_update.METRICS_MIRRORS.items(): repository = pool[manager_id].repository_url assert repository, f"{manager_id} mirrors no repository" assert urlparse(repository).netloc not in forges, ( f"{manager_id} needs no mirror: {repository} is sampled directly" ) assert urlparse(mirror).netloc in forges
[docs] def test_metrics_charts_are_committed(): """Check every configured chart is committed as a rendered SVG. The charts are drawn by the sampler's weekly run, and the docs build reads the committed files to stay hermetic: a missing one means a page embeds nothing where a chart belongs. """ for chart in _metrics_config()["charts"]: path = PROJECT_ROOT / chart["output"] assert path.is_file(), f"{chart['output']} was never drawn" svg = path.read_text(encoding="UTF-8") assert svg.startswith("<svg ") # An accessible name, since the chart carries meaning no caption # repeats. assert f'aria-label="{chart["title"]}"' in svg
[docs] def test_metrics_predecessor_is_sampled(): """Check every declared forerunner has readings of its own in the store. The renderer draws it beside its successor, dashed and never joined: a forerunner the sampler never read would draw nothing, silently. """ config = _metrics_config() rows = _load_metrics_store() for successor, forerunner in config["predecessors"].items(): assert successor in config["subjects"] url = _canonical_repo_url(forerunner) assert any(row["repo"] == url for row in rows), ( f"no reading recorded for {forerunner}" )
[docs] def test_metrics_series_start_at_creation(): """Check every charted repository starts its curve on its creation day. The relative chart measures each curve from its first point, so that point must fall on the day the repository was created. Otherwise a curve starts at its first reading instead, and the chart no longer compares the projects by age. Two shapes satisfy it. The usual one is a `created` row: the sampler writes it at zero stars, and it precedes every measurement. The other is a repository whose historical backfill already covers its creation day. A real reading of that day outranks the assumed zero and replaces it, since the store holds one row per day. So a series with no `created` row must open on a backfilled reading. Opening on a forward `sample` instead means the anchor was never written, and the curve starts on whichever day mpm first read the repository. """ config = _metrics_config() rows = _load_metrics_store() for name in ("mpm", *_docs.BENCHMARK_COMPETITORS): url = _canonical_repo_url(config["subjects"][name]) star_rows = [ row for row in rows if row["repo"] == url and row["metric"] == "stars" ] assert star_rows, f"{name} has no star reading" origins = [row for row in star_rows if row["source"] == "created"] if origins: assert origins[0]["value"] == "0" # The anchor must precede every measurement of that repository. assert all(row["date"] >= origins[0]["date"] for row in star_rows) continue # No anchor, so the backfill that displaced it must open the series. first = min(star_rows, key=lambda row: row["date"]) assert first["source"] != "sample", ( f"{name} has no creation anchor and opens on a forward sample" )
[docs] def test_metrics_store_well_formed(): """Check the committed metrics store keeps the shape its readers expect. Guards a file a scheduled job rewrites unattended: a repository that left the subjects, a duplicated reading, or an unknown provenance would all surface here rather than in a rendered table or misdrawn chart nobody re-reads. """ config = _metrics_config() tracked = { _canonical_repo_url(target) for target in ( *config["subjects"].values(), *config["predecessors"].values(), ) } rows = _load_metrics_store() assert rows, "no metric reading recorded yet" today = datetime.now(tz=UTC).date() star_days = set() attributes = set() releases = set() release_sources = set() for row in rows: assert set(row) == {"date", "metric", "repo", "source", "value"} assert row["repo"] in tracked assert row["metric"] in {"commit", "release", "release_source", "stars"} assert row["source"] in { "created", "github", "sample", "star-history", "wayback", } # Dates are plain ISO days, never timestamps. assert date.fromisoformat(row["date"]) <= today if row["metric"] == "stars": assert int(row["value"]) >= 0 key = (row["repo"], row["date"]) assert key not in star_days, f"duplicate reading for {key}" star_days.add(key) else: # An attribute keeps one row: a moved value replaces it rather # than appending. key = (row["repo"], row["metric"]) assert key not in attributes, f"duplicate attribute for {key}" attributes.add(key) if row["metric"] in {"commit", "release"}: date.fromisoformat(row["value"]) if row["metric"] == "release": releases.add(row["repo"]) if row["metric"] == "release_source": assert row["value"] in {"release", "tag"} release_sources.add(row["repo"]) # A release date always states where it came from, and never the reverse. assert releases == release_sources # Sorted by repository, metric and date, so a scheduled commit reads as an # append per subject. keys = [(row["repo"], row["metric"], row["date"]) for row in rows] assert keys == sorted(keys)
[docs] def test_metrics_subjects_match_benchmark(): """Check the charted subjects are exactly `mpm` plus every competitor. The charts and the benchmark's own columns must never drift apart: a competitor added to the comparison without a repository to sample would silently go unplotted. """ config = _metrics_config() charted = ["mpm", *_docs.BENCHMARK_COMPETITORS] for name in charted: assert name in config["subjects"], f"{name} has no repository to sample" target = config["subjects"][name] owner, _, repo = target.partition("/") assert owner and repo, f"{name} needs an owner/name slug, got {target!r}" # Every plotted series needs a hue, in the same fixed order. assert list(config["colors"]) == charted # And every chart names the series it draws, since the default would plot # all hundred-odd subjects. for chart in config["charts"]: assert list(chart["only"]) in (charted, ["mpm"]), chart["output"]
[docs] def test_binaries_download_table_renders(): """Check the latest-release binaries table generator still works. It must produce a well-formed table from the binaries catalog. The table is rendered live at Sphinx build time by the ``{python:render}`` block in `docs/install.md`, so there is no checked-in copy to compare against: this test only guards the generator against crashes and drift in the `docs/assets/binaries.csv` cell markup it parses. """ table = _docs.binaries_download_table() lines = table.splitlines() assert len(lines) == 5 assert lines[0].startswith("| Platform") for os_label in ("Linux", "macOS", "Windows"): assert any(f"**{os_label}**" in line for line in lines) # The release pipeline builds one binary per OS/arch pair: every cell # must carry a versioned download link. assert sum(line.count("releases/download/") for line in lines) == 6 assert "latest/download" not in table
[docs] def test_fan_out_covers_every_subcommand(): """Check the fan-out catalog names every subcommand the CLI registers. `COMMAND_FAN_OUT` is the only place a reader learns whether a command parallelizes, and it cannot be derived: the mode is an argument at each CLI call site. Holding it equal to the CLI's own command list is what stops a new subcommand from silently missing off `docs/concurrency.md`, and forces whoever adds one to answer the question rather than leave it open. """ catalogued = {entry.command for entry in COMMAND_FAN_OUT} assert catalogued == set(mpm.commands), ( f"uncatalogued: {sorted(set(mpm.commands) - catalogued)}, " f"unknown commands: {sorted(catalogued - set(mpm.commands))}" ) known_modes = { FAN_OUT_CONCURRENT, FAN_OUT_GROUPED, FAN_OUT_NONE, FAN_OUT_SEQUENTIAL, } assert all(entry.mode in known_modes for entry in COMMAND_FAN_OUT) invocations = [entry.invocation for entry in COMMAND_FAN_OUT] assert invocations == sorted(invocations), "COMMAND_FAN_OUT must be sorted" assert len(invocations) == len(set(invocations))
[docs] def test_concurrency_table_renders(): """Check the concurrency table generator produces a well-formed table. Rendered live at Sphinx build time by the ``{python:render}`` block in `docs/concurrency.md`, so there is no checked-in copy to compare against. Every rendered row must carry a glyph the page's own legend defines, and the no-fan-out entries must stay out. """ table = _docs.concurrency_table() lines = table.splitlines() rows = lines[2:] assert lines[0].startswith("| Command") fanning_out = [e for e in COMMAND_FAN_OUT if e.mode != FAN_OUT_NONE] assert len(rows) == len(fanning_out) glyphs = _docs.FAN_OUT_GLYPHS.values() assert all(any(glyph in row for glyph in glyphs) for row in rows) # The three modes each own a row, so the legend never documents a glyph # the table stopped using. for glyph in _docs.FAN_OUT_GLYPHS.values(): assert any(glyph in row for row in rows), f"{glyph} is legended but unused" assert not any("`mpm which`" in row or "`mpm help`" in row for row in rows)
[docs] def test_lock_family_backends_are_distinct(): """Check no lock family is named after a manager, or after another family. Mermaid identifies a sankey node by its label, so a family sharing a label with one of its own members would collapse the diagram's two levels into a single self-linked node. Four backends (`conda`, `pacman`, `pkg`, `scoop`) lend their name to a manager, which is exactly how that happens. """ backends = [family.backend for family in SHARED_LOCK_FAMILIES] assert len(backends) == len(set(backends)), "two lock families share a name" assert not set(backends) & set(pool.all_manager_ids), ( "a lock family is named after a manager, which would fold the sankey's " "two levels into one self-linked node" )
[docs] def test_lock_families_render(): """Check both concurrency-page generators cover every lock family. The sankey and the table are two readings of the same constant, so a family added to `SHARED_LOCK_FAMILIES` must surface in both without further edits. """ sankey = _docs.lock_families_sankey() table = _docs.lock_families_table() for family in SHARED_LOCK_FAMILIES: assert f",{family.backend},{len(family.members)}" in sankey assert family.contention in table for manager_id in family.members: assert f"{family.backend},{manager_id},1" in sankey assert f"[`{manager_id}`](managers/{manager_id}.md)" in table # Managers outside a family are deliberately absent: the diagram is the # serialized population, not the pool. grouped = {mid for f in SHARED_LOCK_FAMILIES for mid in f.members} assert not any(f",{mid}," in sankey for mid in set(pool.all_manager_ids) - grouped)
[docs] def test_augmentations_table_renders(): """Check the augmentations table generator still produces a valid table. The table is generated from the current pool. The table is rendered live at Sphinx build time by the ``{python:render}`` block in `docs/augmentations.md`, so there is no checked-in copy to compare against: this test only guards the generator against crashes and structural regressions. """ table = _docs.augmentations_table() lines = table.splitlines() assert len(lines) > 2 assert lines[0].startswith("| Manager") rows = lines[2:] # Only managers gaining at least one backfilled capability are listed. assert 0 < len(rows) < len(pool) assert all(line.count("✅") >= 1 for line in rows) # The one-by-one upgrade fallback backfills pip, the canonical example # narrated in the page's prose. Its identifier links to its documentation # page. assert any( line.startswith("| [`pip`](managers/pip.md) ") and "✅" in line for line in rows )
[docs] @pytest.mark.parametrize("outcome", PackageOutcome) def test_upgrade_report_legend(outcome): """The legend of the upgrade report names every outcome with its glyph.""" page = PROJECT_ROOT.joinpath("docs", "augmentations.md").read_text(encoding="UTF-8") assert f"| {outcome.label} " in page
[docs] def test_ansi_logo_tracks_the_brand_palette(): """Check the terminal mark names the very colors the artwork is drawn in. `meta_package_manager.logo` repeats them as literals, the SVG sources living in `docs/` and never reaching the wheel. That repetition is how the terminal mark came to shade its faces with a purple the artwork had already dropped, so the sources are what the constants are checked against: the brand SVGs must use these three values, and nothing else. """ # Two brand colors and the midpoint a flat isometric solid needs for its # third plane, which must be exactly that: a midpoint, not a fourth choice. assert logo.BRAND_MID == "#{:02x}{:02x}{:02x}".format( *( (int(logo.BRAND_INK[i : i + 2], 16) + int(logo.BRAND_WASH[i : i + 2], 16)) // 2 for i in (1, 3, 5) ) ) palette = {logo.BRAND_INK, logo.BRAND_MID, logo.BRAND_WASH} # The one derived value the artwork holds: the social banner's background, # which is the wash over white and so cannot be any of them literally. derived = {"#e7e7fa"} for name in ( "banner-social.svg", "favicon.svg", "icon.svg", "logo-banner.svg", "logo-square.svg", ): source = (PROJECT_ROOT / "docs" / "assets" / name).read_text(encoding="UTF-8") # Inkscape's editor chrome is not artwork: it never renders. source = re.sub(r"<sodipodi:namedview[\s\S]*?/>", "", source) colors = {match.lower() for match in re.findall(r"#[0-9a-fA-F]{6}", source)} assert colors - derived == palette, f"{name} strays from the brand palette" # Three tones, in the order they shade an isometric solid: the lit faces, # the interpolation, then the shadowed ones. assert list(logo.TONES) == [".", ":", "+"] assert len(set(logo.TONES.values())) == 3
[docs] def test_manager_logo_assets(): """Check every vendored brand mark is normalized, safe and accounted for. The SVGs are inlined verbatim into the built pages, so this is what keeps a hand edit (or a compromised upstream) from smuggling a script or a remote reference into the documentation. `docs/logos_update.py` produces this shape; the directory holds nothing else. """ manifest = _docs.logo_manifest() assert set(manifest) == {"icons", "upstream"} assert list(manifest["icons"]) == sorted(manifest["icons"]) assets = {path.stem for path in _docs.LOGO_DIR.glob("*.svg")} assert assets == set(manifest["icons"]), "manifest and assets drifted apart" for slug, icon in manifest["icons"].items(): assert set(icon) == { "contrast_on_light", "hex", "license", "managers", "source", "title", } assert icon["managers"] == sorted(icon["managers"]) assert re.fullmatch(r"[0-9A-F]{6}", icon["hex"]) assert icon["source"].startswith("https://") svg = (_docs.LOGO_DIR / f"{slug}.svg").read_text(encoding="utf-8") # One line, so the raw HTML block survives its injection into MyST: a # blank line would end the block and leak markup into the page. assert svg.count("\n") == 1 assert svg.endswith("\n") assert svg.startswith('<svg viewBox="0 0 24 24">') assert f"<title>{icon['title']}</title>" in svg # No hard-coded color, so the mark inherits the theme's currentColor. assert 'fill="' not in svg assert not re.search(r"<(script|image|foreignObject)\b|xlink:href|url\(", svg)
[docs] def test_manager_logos_resolve(): """Check the `logo` slug of every manager points at a vendored mark. Also enforces the reverse: a mark nobody claims is dead weight, mirroring the orphan sweep `update_manager_stubs` performs on `docs/managers/`. """ icons = _docs.logo_manifest()["icons"] claimed: dict[str, list[str]] = {} for mid, manager in pool.items(): if manager.logo: assert manager.logo in icons, f"{mid} declares an unvendored logo" claimed.setdefault(manager.logo, []).append(mid) assert claimed, "no manager declares a logo anymore" for slug, icon in icons.items(): assert icon["managers"] == sorted(claimed.get(slug, [])), ( f"the {slug} mark is credited to the wrong managers" )
[docs] @all_managers def test_manager_logo_renders(manager): """Check the brand mark of each manager renders as inlinable raw HTML.""" mark = _docs.manager_logo(manager.id) if not manager.logo: assert mark == "" # A manager with no mark keeps the page's default package glyph. assert "manager-logo" not in _docs.manager_intro(manager.id) return assert mark.startswith('<div class="manager-logo"') assert mark.endswith("</div>") assert "\n" not in mark assert mark in _docs.manager_intro(manager.id) # The brand color always rides on a custom property: WCAG exempts logotypes # from contrast requirements, so a pale mark keeps its own color rather than # being repainted a flat black. icon = _docs.logo_manifest()["icons"][manager.logo] assert f"--manager-logo-color: #{icon['hex']}" in mark # The index table embeds the small variant, as a span: a table cell wraps # its content in a paragraph, where a <div> would be invalid markup. assert _docs.manager_logo(manager.id, inline=True).startswith( '<span class="manager-logo manager-logo-inline"' )
[docs] @all_managers def test_manager_card_renders(manager): """Check each manager's infobox carries its facts, and its mark when it has one.""" card = _docs.manager_card(manager.id) assert card.startswith("```{card}\n:class-card: manager-card") # noqa: RUF027 assert card.endswith("```") assert card in _docs.manager_intro(manager.id) # Every fact is a definition-list row, so it reads as a labelled entry. for label in ("ID", "Issues and PRs", "Source", "Platforms"): assert f"**{label}**\n: " in card # The former Platforms and Ecosystem sections now live here. assert "**purl types**\n: `pkg:" in card # The Brewfile keyword is not a card fact: `--brewfile` is an option of one # command, so `docs/dump.md` states the whole mapping once, in a table. assert "Brewfile entry" not in card # The tracker link and the source file close the box, in that order: a # reader after either has read everything else first. Managers sharing an # ecosystem share one label, hence one search. label_name = MANAGER_LABELS[manager.id] assert label_name.startswith(MANAGER_PREFIX) url = _docs.manager_label_url(manager.id) badge = f"{{bdg-link-secondary}}`{label_name} <{url}>`" assert f"**Issues and PRs**\n: {badge}\n\n**Source**\n: " in card assert "**" not in card.partition("**Source**\n: ")[2] # The link reads as the file name alone, which is unambiguous only while every # manager lives in the one package directory. source_url = _docs.manager_source_url(manager.id) source_dir, _, source_file = source_url.partition("#")[0].rpartition("/") assert source_dir == f"{_docs.GITHUB_BLOB_URL}/meta_package_manager/managers" assert f"**Source**\n: [`{source_file}`]({source_url})" in card # Only the ASCII specials are escaped, the way GitHub's own label links are, # and the search is narrowed to what is still open: a closed backlog is not # what a reader clicking the badge came for. quoted = label_name.replace(" ", "%20").replace(":", "%3A") assert url == (f"{_docs.GITHUB_ISSUES_URL}?q=label%3A%22{quoted}%22%20state%3Aopen") # How mpm invokes the tool, the whole of what used to be a section of its own. cli_row = "CLI names (lookup order)" if len(manager.cli_names) > 1 else "CLI name" assert f"**{cli_row}**\n: " in card for name in manager.cli_names: assert f"`{name}`" in card assert ("**Extra search paths**" in card) is bool(manager.cli_search_path) assert ("**Forced environment**" in card) is bool(manager.extra_env) # A single row states arguments forced before the command, after it, or # both, as the shape of every invocation. forced = card.partition("**Every call**\n: ")[2].partition("\n")[0] assert bool(forced) is bool(manager.pre_args or manager.post_args) if forced: argv = " ".join(( manager.cli_names[0], *manager.pre_args, "<command>", *manager.post_args, )) assert forced == f"`{argv}`" # No path leaks the home directory of whoever built the docs. assert str(Path.home()) not in card # The glue holding a separator to the value before it, and an icon to its # label, is a non-breaking space. It survives no formatter that mistakes it # for whitespace, so assert it rather than trust it. assert _docs.FACT_SEPARATOR.startswith("\u00a0") platforms = _docs.manager_platforms(manager.id) assert "\u00a0" in platforms or not platforms # Every repeated value in the box reads through the same separator. A # coverage annotation carries commas of its own (`Mageia, Mandriva Linux # only`), so only what sits between entries is checked. for row in ("Platforms", "Operations", "purl types"): value = card.partition(f"**{row}**\n: ")[2].partition("\n")[0] assert ", " not in re.sub(r"\([^)]*\)", "", value), ( f"{row} must not fall back to commas" ) assert f": `{manager.id}`" in card # The home page, the tool's own manual, the repository and the Wikipedia # article, each a fixed label after its icon, inside a link the stylesheet # keeps whole. targets = [ (icon, label, url) for icon, label, url in ( ("home", "Home page", manager.homepage_url), ("book", "Documentation", manager.documentation_url), ("code", "Repository", manager.repository_url), ("globe", "Wikipedia", manager.wikipedia_url), ) if url ] links = [ f"[{{octicon}}`{icon}` {label}]({url}){{.manager-link}}" for icon, label, url in targets ] assert links, f"{manager.id} links nothing" label = "Links" if len(links) > 1 else "Link" assert f"**{label}**\n: {_docs.FACT_SEPARATOR.join(links)}\n" in card if manager.requirement: # Unstyled like the readme matrix's own Version column, with both angle # brackets escaped: a leading `>` would otherwise open a blockquote and # swallow itself, and a `<` would open a tag. requirement = ( _docs ._format_requirement(manager.requirement) .replace("<", r"\<") .replace(">", r"\>") ) assert f"**Version requirement**\n: {requirement}" in card assert "\n: >" not in card # Spaced out for reading, the same way the readme matrix renders it: no # comparison operator stays glued to the version it applies to. assert not re.search(r"[<>=!~]\d", requirement) assert f": `{manager.requirement}`" not in card assert ("**Cooldown**\n: ✓" in card) is bool(manager.supports_cooldown) # The mark rides in the card header, above the `^^^` separator. header, _, body = card.partition("^^^") assert ("manager-logo" in header) is bool(manager.logo) assert "manager-logo" not in body
[docs] def test_manager_label_badge_color(): """Check the stylesheet paints the label badge the color GitHub gives it. A stylesheet cannot read `labels.py`, so the hex is written twice: here is where the copy is caught drifting from {data}`~meta_package_manager.labels.MANAGER_LABEL_COLOR`. """ css = (PROJECT_ROOT / "docs" / "_static" / "custom.css").read_text( encoding="utf-8", ) rule = css.partition(".manager-card a.sd-badge {")[2].partition("}")[0] assert rule.count(MANAGER_LABEL_COLOR) == 2, "background and border color"
[docs] def test_manager_logo_credits_renders(): """Check every vendored mark is credited with its license and source. Twelve marks carry an attribution-bearing license, so a missing row is a license violation rather than a cosmetic gap. """ credits = _docs.manager_logo_credits() assert credits.startswith("Brand marks come from [Simple Icons]") rows = [line for line in credits.splitlines() if line.startswith("| [")] icons = _docs.logo_manifest()["icons"] assert len(rows) == len(icons) for icon in icons.values(): assert f"[{icon['title']}]({icon['source']})" in credits for mid in icon["managers"]: assert f"[`{mid}`](managers/{mid}.md)" in credits
[docs] def test_manager_stubs_in_sync(): """Check the page stubs of `docs/managers/` match a fresh generation. The committed stubs are generated from the pool. The directory is wholly owned by `update_manager_stubs()`: one stub per pool manager, nothing else, each byte-identical to its template. Drift means a manager was added or removed without running `docs/docs_update.py` (repomatic's `update-docs` job self-heals this on the next push). """ stub_dir = PROJECT_ROOT / "docs" / "managers" stubs = {path.stem: path for path in stub_dir.glob("*.md")} assert set(stubs) == set(pool.all_manager_ids) for mid, path in stubs.items(): assert path.read_text(encoding="utf-8") == _docs.manager_page_stub(mid)
[docs] def test_api_stubs_are_reachable_from_a_toctree(): """Every generated API page sits in the toctree of its parent index. A page in no toctree still builds, so Sphinx reports it as a warning and the build stays green: `tests.test_manager_yarn` was unreachable from `tests.md` for as long as the module existed. The parent is the longest `<prefix>.md` above a page, which is what puts a sub-package's modules in its own index rather than the top-level one. """ docs = PROJECT_ROOT / "docs" pages = {path.stem for path in docs.glob("*.md") if "." in path.stem} indexes = {"meta_package_manager", "tests"} | pages def parent_of(stem: str) -> str | None: parts = stem.split(".") for cut in range(len(parts) - 1, 0, -1): candidate = ".".join(parts[:cut]) if candidate in indexes and (docs / f"{candidate}.md").exists(): return candidate return None listed = {} for stem in indexes: index = docs / f"{stem}.md" if index.exists(): listed[stem] = set( re.findall( r"^\s+(\w+(?:\.\w+)+)\s*$", index.read_text(encoding="UTF-8"), re.MULTILINE, ) ) orphans = [] for stem in sorted(pages): if not stem.startswith(("meta_package_manager.", "tests.")): continue parent = parent_of(stem) if parent is None: orphans.append(f"{stem}: no parent index page") elif stem not in listed[parent]: orphans.append(f"{stem}: missing from {parent}.md") assert not orphans, "API pages outside every toctree:\n" + "\n".join(orphans) dangling = [ f"{entry}: listed in {stem}.md but no page" for stem, entries in listed.items() for entry in sorted(entries) if entry.startswith(("meta_package_manager", "tests")) and entry not in pages and not (docs / f"{entry}.md").exists() ] assert not dangling, "toctree entries with no page:\n" + "\n".join(dangling)
[docs] def test_manager_page_headings_survive_a_build(tmp_path): """Check a real Sphinx build turns the generated headings into sections. Under a generated title and below the lede. The stub carries no heading at all, not even the page title: every one is printed by `manager_page()` and parsed out of the directive's output, which only works because `myst-parser` supports titles in a nested parse. Three ways that can regress, all silent, none visible to a test of the generators alone: the document can lose its title, the sections can vanish, or the lede can be reparented below them, which is what happens the moment anything is printed above the first heading of the parse. So one page is built for real, and its title, heading sequence and infobox position are asserted. `brew` is the subject: it is the one manager exercising every section. Skipped when the `docs` dependency group is missing, as in the hermetic unit-test environment. """ pytest.importorskip("myst_parser") pytest.importorskip("sphinx_design") build = pytest.importorskip("sphinx.cmd.build") try: import click_extra.sphinx # noqa: F401, PLC0415 except ImportError: pytest.skip("needs the docs dependency group (click-extra[sphinx])") manager_id = "brew" source = tmp_path / "source" source.mkdir() (source / "conf.py").write_text( # The subset of docs/conf.py the page needs: the directive that runs # the generator, and the MyST extensions its output relies on. 'project = "test"\n' 'extensions = ["myst_parser", "sphinx_design", "click_extra.sphinx"]\n' "click_extra_enable_exec_directives = True\n" 'myst_enable_extensions = ["attrs_block", "attrs_inline", ' '"colon_fence", "deflist"]\n', encoding="UTF-8", ) (source / "index.md").write_text( _docs.manager_page_stub(manager_id), encoding="UTF-8" ) build_dir = tmp_path / "build" argv = ["--builder", "html", "--fresh-env", str(source), str(build_dir)] assert build.build_main(argv) == 0 html = (build_dir / "index.html").read_text(encoding="UTF-8") # The generated title is the document's own: Sphinx promoted it out of the # directive's output, which is what feeds the toctree label and the tab. assert f"<title>{pool[manager_id].name}" in html # Backticked identifiers render as code spans, and every heading trails the # permalink anchor Sphinx appends. The level matters as much as the text: a # subsection flattened by the nested parse would still read as a heading. rendered = [ (int(level), re.sub(r"<[^>]+>", "", text).strip()) for level, text in re.findall(r"<h([23])>(.*?)<a class", html, re.DOTALL) ] expected = [ (level, title.format(manager_id=manager_id).replace("`", "")) for title, func_name, level in _docs.MANAGER_SECTIONS if title and getattr(_docs, func_name)(manager_id).strip() ] assert rendered == expected # The lede opens the page: its infobox stands above the first heading, # neither swept below the last section by the nested parse nor rendered a # second time by a stub that also delegates it to `manager_page()`. assert html.count("manager-card") == 1 assert html.index("manager-card") < html.index("<h2>")
[docs] @all_managers def test_manager_page_sections_render(manager): """Check every section generator of the manager pages makes valid MyST. The MyST must be non-empty and heading-free. The sections are rendered live at Sphinx build time by the single ``{python:render}`` block of `docs/managers/<id>.md`, so there is no checked-in copy to compare against: this test guards the generators against crashes and locks the heading-free invariant documented on `MANAGER_SECTIONS` (a heading comes from `manager_page()` alone). """ heading = re.compile(r"^#{1,6} ", re.MULTILINE) fence = re.compile(r"(?ms)^(`{3,}).*?^\1$") for _title, func_name, _level in _docs.MANAGER_SECTIONS: output = getattr(_docs, func_name)(manager.id) # Four sections are omitted for some managers (a section with no output # is dropped from the page by manager_page): reference traces for a # manager documenting no literal output samples, the Rosetta table for # one documenting fewer than three harvestable native commands, the # upstream badges for one whose project has no repository the sampler # could read, and concurrency for one sharing no backend lock. Every # other section renders for every manager. if func_name not in ( "manager_concurrency", "manager_rosetta", "manager_traces", "manager_upstream", ): assert output.strip() # Fenced blocks (code samples, eval-rst) cannot produce MyST headings: # only the prose between them must stay heading-free. assert not heading.search(fence.sub("", output)) upstream = manager.homepage_url or manager.repository_url assert upstream in _docs.manager_intro(manager.id) # Header, separator, then one row per operation. operations = _docs.manager_operations(manager.id) assert len(operations.splitlines()) == 2 + len(Operations) configuration = _docs.manager_configuration(manager.id) assert f"--no-{manager.id}" in configuration assert f"[mpm.overrides.{manager.id}]" in configuration
[docs] @all_managers def test_operation_notes_reach_the_operations_table(manager): """Check every declared operation note lands verbatim in the table. The *Notes* column renders the declaration beside its operation row, so a note missing from the table names a key the renderer dropped: an operation renamed, or a vocabulary drift between {attr}`~meta_package_manager.manager.PackageManager.operation_notes` and {class}`~meta_package_manager.capabilities.Operations`. """ operations = _docs.manager_operations(manager.id) for op_name, note in manager.operation_notes.items(): assert note in operations, f"The {op_name!r} note is missing from the table."
[docs] @all_managers def test_operation_notes_are_full_sentences(manager): """Check every *Notes* cell of the operations table reads as sentences. Each populated cell must read as standalone sentences. The column mixes the manager's declared notes with the synthesized backfill captions, so the sentence shape (a capital to open, a period to close, no leading code span) is what keeps the two halves reading alike. Rows are parsed off the rendered table so the check covers both sources at once. """ for line in _docs.manager_operations(manager.id).splitlines(): if not line.startswith("| `"): continue cells = [cell.strip() for cell in line.split("|")] if len(cells) < 5: # No Notes column. continue note = cells[3] if not note: continue assert note[0].isupper(), f"A note cell does not open on a capital: {note!r}." assert not note.startswith("`"), f"A note cell opens on a code span: {note!r}." assert note.endswith("."), f"A note cell does not close on a period: {note!r}."
[docs] def test_version_probe_nests_under_the_reference_traces(): """Check the version probe is a subsection of the reference traces. On a page carrying no traces, it is a section of its own. `MANAGER_SECTIONS` declares the probe one level below `manager_traces`, and `manager_page()` promotes a subsection whose parent rendered nothing. Both halves regress silently: a lost level flattens the probe back into a section, and a lost promotion orphans it under whatever heading precedes the traces on every page documenting no native output sample. """ for manager_id in pool.all_manager_ids: page = _docs.manager_page(manager_id) if _docs.manager_traces(manager_id).strip(): assert "\n## Reference traces\n" in page, manager_id assert "\n### Version check\n" in page, manager_id assert page.index("## Reference traces") < page.index( "### Version check" ), manager_id else: assert "\n## Version check\n" in page, manager_id assert "### Version check" not in page, manager_id
[docs] @pytest.mark.parametrize("manager_id", ["brew", "pear"]) def test_config_template_block_is_the_command_output(manager_id, invoke): """Check a manager page shows the block `mpm config-template` prints. The page composes the command's own pieces, so a change to its output (a header comment, a renamed table, a different wrapping) would otherwise leave every page showing a block the CLI no longer produces. `brew` adds an `extra_env` sub-table to the shape `pear` has. """ result = invoke("config-template", manager_id) assert result.exit_code == 0 block = f"```toml\n{result.output.rstrip()}\n```" assert indent(block, " ") in _docs.manager_configuration(manager_id)
[docs] def test_configuration_section_names_no_build_host(): """Check no manager page carries a path from the machine that built it. An overridable field can hold one: SDKMAN resolves its search path from `$SDKMAN_DIR`, whose default sits under `$HOME`, and the template block prints the value as it stands. `_config_template` collapses a path under the builder's home to `~`, the rule the infobox already applies, so the published pages are identical on every host. """ home = str(Path.home()) for manager_id in pool.all_manager_ids: assert home not in _docs.manager_configuration(manager_id), manager_id
DEFINITION_ONLY_FIELDS = sorted( set(definitions.DEFINITION_CLI_FIELDS) - set(definitions.OVERRIDABLE_FIELDS) ) """Fields a manager definition accepts and an override does not."""
[docs] @pytest.mark.parametrize("field", DEFINITION_ONLY_FIELDS) def test_definition_only_fields_are_documented(field): """Check each definition-only field is in its two reference lists. The table of `docs/overrides.md` is the list a user reads, and the one under {data}`~meta_package_manager.definitions.DEFINITION_CLI_FIELDS` the list a developer reads. Both are written by hand, so a field added to the mapping reaches neither on its own. """ page = PROJECT_ROOT.joinpath("docs", "overrides.md").read_text(encoding="UTF-8") assert f"| `{field}` " in page source = Path(definitions.__file__).read_text(encoding="UTF-8") assert f"\n- `{field}` " in source
DOCSTRING_FENCE = re.compile( r"(?ms)^([ \t]*)(`{3,})(?P<info>[^\n]*)\n.*?^[ \t]*\2[ \t]*$" ) """Backtick-fenced block of a docstring, whatever its indentation. The info string stops at the newline: `.` spans lines under `re.DOTALL`, so a greedy one would swallow every fence of the docstring into a single match. """ PROSE_DIRECTIVES = re.compile(r"\{(?!code-block|literalinclude|eval-rst)") """Fence info string of a directive whose body is prose, not code. An admonition fence (note, warning, caution, seealso, todo) renders its content as regular MyST, so its links matter as much as the surrounding paragraphs'. Only a code block escapes the prose rules. """ BARE_URL = re.compile(r"(?<!\]\()(?<!<)https?://[^\s>)\]`,;\'\"]+") """A URL that is neither a markdown link target nor an autolink. The single `<` lookbehind covers autolinks (`<https://â€Ļ>`) and, incidentally, angle-bracketed link destinations (`](<https://â€Ļ>)`); the latter are rejected outright by `ANGLE_LINK_DEST` below, so they never need blessing here. """ ANGLE_LINK_DEST = re.compile(r"\]\(<") """A markdown link whose destination hides in angle brackets. CommonMark allows the form, but the MyST docstring converter reads a destination only up to the first closing parenthesis, so `](<â€Ļ>)` renders as broken markup — worse than a bare URL. Rewrite the URL to a parenthesis-free variant instead (the man-page query form, say). Lift this ban only when the click-extra floor rises to a release whose converter takes the bracketed form. """ def _strip_code_fences(docstring: str) -> str: """Drop the code blocks of a docstring, keeping every prose directive. A captured CLI transcript is data and escapes the prose rules; an admonition's body does not, so its own fence stays in. """ return DOCSTRING_FENCE.sub( lambda match: ( match.group(0) if PROSE_DIRECTIVES.match(match.group("info").strip()) else "" ), docstring, ) def _prose_docstrings(): """Yield every docstring of the package, code fences stripped out. Covers the four kinds `autodoc` renders: module, class, function and attribute docstrings (a bare string expression trailing an assignment). """ for path in sorted((PROJECT_ROOT / "meta_package_manager").rglob("*.py")): tree = ast.parse(path.read_text(encoding="UTF-8")) for node in ast.walk(tree): if isinstance( node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef), ): doc = ast.get_docstring(node, clean=False) if doc: yield path, node.body[0].lineno, _strip_code_fences(doc) previous = None # `body` is a single expression on a lambda or a ternary, not a block. body = getattr(node, "body", None) for child in body if isinstance(body, list) else (): if ( isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant) and isinstance(child.value.value, str) and isinstance(previous, (ast.Assign, ast.AnnAssign)) ): doc = _strip_code_fences(child.value.value) yield path, child.lineno, doc previous = child
[docs] def test_docstrings_carry_no_bare_url(): """Every URL in docstring prose must be a link. MyST's `linkify` extension is off, so a bare URL renders as dead plain text, both in the API docs and in the manager pages inlining the class docstrings. URLs inside a fenced block are captured CLI output (fwupd's `See https://â€Ļ` warnings) and are left alone. Write a titled markdown link, and keep its `]` and `(` on one source line: a line break between them disables the link with no warning. """ bare = [ f"{path.relative_to(PROJECT_ROOT)}:{lineno}: {match.group()}" for path, lineno, doc in _prose_docstrings() for match in BARE_URL.finditer(doc) ] assert not bare, "Bare URLs in docstrings:\n" + "\n".join(bare)
TRAIL_FAILURE_GLYPH = "\u2718" """The failure glyph every description of the trail in this tree writes. Pinned to click-extra's `KO_GLYPH` by `test_trail_glyph_matches_the_emitted_one`: the prose follows the emitted character. Its look-alike U+2717 reads the same on screen and matches nothing in real output. """
[docs] def test_trail_glyph_matches_the_emitted_one(): """Prose about the trail writes the glyph the trail emits. A reader grepping the documented failure glyph must find it in real output, so the tree carries `KO_GLYPH` and never its U+2717 look-alike. The scan is raw text, comments included: a captured fixture printing U+2717 would need an exclusion here, and none exists today. The bundled skills are scanned as well as `docs/`, because a skill body publishes: `docs/add-new-manager.md` is a bare `{include}` of `.claude/skills/add-manager/SKILL.md`, so a `docs/*.md` glob reads the three lines of the directive and never the prose the page renders. """ assert KO_GLYPH == TRAIL_FAILURE_GLYPH files = [ *sorted((PROJECT_ROOT / "meta_package_manager").rglob("*.py")), *sorted((PROJECT_ROOT / "tests").glob("*.py")), *sorted((PROJECT_ROOT / "docs").glob("*.md")), *sorted((PROJECT_ROOT / ".claude" / "skills").rglob("*.md")), PROJECT_ROOT / "AGENTS.md", PROJECT_ROOT / "readme.md", ] hits = [] for path in files: lines = path.read_text(encoding="UTF-8").splitlines() for lineno, line in enumerate(lines, 1): if "\u2717" in line: hits.append(f"{path.relative_to(PROJECT_ROOT)}:{lineno}") assert not hits, "U+2717 where the trail emits U+2718:\n" + "\n".join(hits)
[docs] def test_self_referencing_urls_name_a_file_the_tree_holds(): """A raw URL to this repository's `main` names a file the tree carries. Applies to `raw.githubusercontent.com` URLs. Nothing else resolves these: they are fetched by a reader far from any test. The `<xbar.image>` header of the bar plugin is what xbarapp.com renders as the plugin's preview, and the readme images are served to every GitHub visitor. So renaming a file under `docs/assets/` breaks them silently, which is how the plugin shipped a dead preview for five weeks after `fc8adbfb` recaptured the screenshots under new names. A URL pinned to a tag names a historical tree and is skipped, the working copy being no evidence about what that tag held. """ url_re = re.compile( r"https://raw\.githubusercontent\.com/kdeldycke/meta-package-manager/" r"(?:refs/heads/)?main/(?P<path>[^\s<>\"')|]+)" ) files = [ *sorted((PROJECT_ROOT / "meta_package_manager").rglob("*.py")), *sorted((PROJECT_ROOT / "tests").glob("*.py")), *sorted((PROJECT_ROOT / "docs").glob("*.md")), *sorted((PROJECT_ROOT / ".claude" / "skills").rglob("*.md")), PROJECT_ROOT / "AGENTS.md", PROJECT_ROOT / "readme.md", ] hits = [] for path in files: lines = path.read_text(encoding="UTF-8").splitlines() for lineno, line in enumerate(lines, 1): for match in url_re.finditer(line): target = match.group("path") if not (PROJECT_ROOT / target).is_file(): source = path.relative_to(PROJECT_ROOT) hits.append(f"{source}:{lineno} names {target}") assert not hits, "URLs naming a file the tree no longer holds:\n" + "\n".join(hits)
[docs] def test_manager_traces_render_literal_blocks(): """The reference traces of a class-based manager match the corpus. They surface exactly the literal installed/outdated/orphans blocks the corpus validates, in terminal-facing form. `manager_traces` and the corpus round-trip both read {func}`~meta_package_manager.docstring_corpus.literal_blocks`, so the rendered traces never drift from what the parsers are tested against. TOML managers keep their `[samples]` traces, covered by `test_bundled_parsing`. """ for mid, manager in pool.items(): if getattr(manager, "definition_source", None): continue traces = _docs.manager_traces(mid) blocks = literal_blocks(type(manager), ("installed", "outdated", "orphans")) assert bool(traces) == bool(blocks), mid for _member, _index, block in blocks: assert block in traces, mid
[docs] def test_manager_changelog_entries(): """Check every changelog bullet reaches the manager pages it is scoped to. The release-history section is rendered live at Sphinx build time, so there is no checked-in copy to compare against: this recounts the (manager, entry) pairs straight from `changelog.md` and matches them against the index, which catches an entry silently dropped by a scope the parser fails to resolve. Every pool manager is asserted to have at least one entry, which makes the section double as the lint for a manager shipped without its changelog line. """ index = _docs._changelog_entries() # The index keys every scope tag, the project-wide and frontend ones # included, so only the pool half is asserted here. assert set(pool.all_manager_ids) <= set(index) raw = PROJECT_ROOT.joinpath("changelog.md").read_text(encoding="utf-8") # The older releases hard-wrap their bullets, so an entry spans several # source lines: compare against the same folded copy the index reads. changelog = re.sub(r"\n[ \t]+(?![-*+] )(?=\S)", " ", raw) heading = re.compile(r"^## \[`([^`]+)` \(([^)]+)\)\]\(([^)]+)\)", re.MULTILINE) releases = set(heading.findall(changelog)) pairs = 0 for mid, entries in index.items(): if mid not in pool: continue rendered = _docs.scope_changelog(mid) # The page opens straight on the newest release that touched it. newest = entries[0] assert rendered.startswith( f"- [`{newest.version}`]({newest.url}) ({newest.date})\n", ) for entry in entries: pairs += 1 assert (entry.version, entry.date, entry.url) in releases # Entry text is reproduced verbatim, flag included, under a release # linking to the comparison URL of its own changelog heading. flag = f"**{entry.flag}:** " if entry.flag else "" assert entry.text in changelog assert f" - {flag}{entry.text}" in rendered # Every bullet is a full sentence, so an entry stopping short of # its period is one whose wrapped tail never made it in. assert entry.text.endswith("."), entry.text assert f"- [`{entry.version}`]({entry.url}) ({entry.date})" in rendered # Independent recount: every bullet scoped to a pool manager lands on that # manager's page, exactly once. expected = sum( 1 for scopes in re.findall( r"^- (?:\*\*[A-Za-z]+:\*\* )?\[([a-z0-9,\-]+)\]", changelog, re.MULTILINE, ) for scope in scopes.split(",") if scope in pool ) assert pairs == expected
[docs] def test_frontend_changelog_sections(): """Check each desktop frontend page renders the changelog scoped to it. Both pages are hand-written, so what pins the section is the pair of ``{python:render}`` blocks, one per frontend, each naming its own scope. A scope the index fails to resolve renders an empty section instead of failing, hence the recount straight from `changelog.md`. """ changelog = PROJECT_ROOT.joinpath("changelog.md").read_text(encoding="utf-8") scopes = re.findall( r"^- (?:\*\*[A-Za-z]+:\*\* )?\[([a-z0-9,\-]+)\]", changelog, re.MULTILINE, ) for scope, page in ( ("bar-plugin", "bar-plugin.md"), ("gnome-shell", "gnome-shell.md"), ): rendered = _docs.scope_changelog(scope) expected = sum(1 for tags in scopes if scope in tags.split(",")) assert expected assert len(re.findall(r"^ - ", rendered, re.MULTILINE)) == expected source = PROJECT_ROOT.joinpath("docs", page).read_text(encoding="utf-8") assert "## Changelog" in source assert f'print(scope_changelog("{scope}"))' in source
[docs] def test_manager_support_bar_renders(): """Check the support bar opens the page with one region per state. Each region is as wide as its own population, and coloured by a step the stylesheet declares. Rendered live at Sphinx build time by the ``{python:render}`` block opening `docs/managers.md`, as raw HTML: the shares are a ratio CSS can draw, and a committed image would go stale on the next manager the pool gains. """ bar = _docs.manager_support_bar() counts = _docs._support_population() benchmark = _docs._load_benchmark_toml() unsupported = benchmark.get("unsupported", {}) queued = benchmark.get("queued", {}) total = len(pool) + len(queued) + len(unsupported) # A group, not an image: its regions are links, which `role="img"` would # hide from anyone not using a mouse. assert bar.startswith('<div class="manager-bar" role="group" aria-label="') assert f"among the {total} package managers assessed so far" in bar assert bar.endswith("</div>") regions = re.findall( r'<a class="manager-bar-region manager-bar-(\d+)" href="#([a-z-]+)"' r' style="flex-grow: (\d+)" title="([^"]+)" aria-label="([^"]+)">' r'<span class="manager-bar-glyph">(.+?)</span></a>', bar, ) # Every state gets a region, in scale order, sized by its own count. assert [region[-1] for region in regions] == list(_docs.SUPPORT_SCALE) assert [region[0] for region in regions] == [ str(i) for i in range(1, len(_docs.SUPPORT_SCALE) + 1) ] assert sum(int(region[2]) for region in regions) == total # Every region lands on the group of the index carrying the same state, and # every state has an anchor to land on. assert set(_docs.SUPPORT_ANCHORS) == set(_docs.SUPPORT_SCALE) index = _docs.managers_index_table() for _, target, grow, tooltip, label, glyph in regions: assert int(grow) == counts[glyph] assert target == _docs.SUPPORT_ANCHORS[glyph] assert index.count(f'id="{target}"') == 1 # Both readings are attributes, where a code span is just punctuation. assert "`" not in tooltip and "`" not in label assert tooltip == f"{glyph} {label}" # The glyph is dropped from the spoken one: a screen reader announcing # "lifebuoy" ahead of the count helps nobody. assert label.startswith(f"{counts[glyph]} managers, ") assert _docs.SUPPORT_SCALE[glyph].replace("`", "") in label # Every region has a step declared for it, in all three theme scopes: the # default, the explicit dark toggle, and the OS dark preference. A state # added without its colour would otherwise render as a transparent gap. css = (PROJECT_ROOT / "docs" / "_static" / "manager-index.css").read_text( encoding="UTF-8" ) for rank in range(1, len(_docs.SUPPORT_SCALE) + 1): assert css.count(f"--manager-bar-{rank}: #") == 3 assert f".manager-bar-{rank} {{" in css # And no orphan step outlives the state it painted. assert css.count("--manager-bar-") == 4 * len(_docs.SUPPORT_SCALE)
[docs] def test_manager_support_legend(): """Check the glyph legend counts every assessed manager exactly once. Rendered live at Sphinx build time by the ``{python:render}`` block opening `docs/managers.md`, right above the index table it is the key to. The population it reports replaced a prose summary, so the numbers are asserted against an independent recount of the same two sources. """ text = _docs.manager_support_legend() data = _docs._load_benchmark_toml() unsupported: dict[str, str] = data.get("unsupported", {}) competitor_data: dict[str, list[str]] = data.get("managers", {}) queued: dict[str, str] = data.get("queued", {}) assessed = len(pool) + len(queued) + len(unsupported) assert text.startswith(f"{assessed} package managers have been assessed") assert "benchmark.md#package-manager-support" in text # One row per state, in scale order, each carrying its glyph, its # population and its meaning. lines = text.splitlines() assert lines[1] == "" assert lines[2].startswith("| Glyph | Managers | Meaning") rows = lines[4:] assert len(rows) == len(_docs.SUPPORT_SCALE) unmaintained = sum(1 for m in pool.values() if m.unmaintained) topgrade_fallback = sum( 1 for mid in unsupported if "topgrade" in competitor_data.get(mid, []) ) dead = sum( 1 for mid, status in unsupported.items() if status == "archived" and "topgrade" not in competitor_data.get(mid, []) ) expected = { _docs.WRAPPED_GLYPHS["maintained"]: len(pool) - unmaintained, _docs.WRAPPED_GLYPHS["unmaintained"]: unmaintained, _docs.QUEUED_GLYPH: len(queued), _docs.TOPGRADE_FALLBACK_GLYPH: topgrade_fallback, _docs.UNSUPPORTED_GLYPHS["archived"]: dead, _docs.UNSUPPORTED_GLYPHS["excluded"]: len(unsupported) - topgrade_fallback - dead, } # The scale is exhaustive: every state is populated, and the six # populations partition the assessed managers. assert list(expected) == list(_docs.SUPPORT_SCALE) assert sum(expected.values()) == assessed for row, (glyph, meaning) in zip(rows, _docs.SUPPORT_SCALE.items()): cells = [cell.strip() for cell in row.split("|")[1:-1]] assert cells == [glyph, str(expected[glyph]), meaning] assert expected[glyph], f"no manager left in the {glyph} state" # The wording the index's own glyphs are read against, never backticked. for glyph in _docs.SUPPORT_SCALE: assert f"`{glyph}`" not in text
def _row_manager_id(row: str) -> str: """Manager ID of a rendered index row: its first backticked link. The `Manager` column holding the prose name never code-spans it, so the first such span is the `ID` cell whatever the row's group. """ match = re.search(r"\[`([a-z0-9.-]+)`\]", row) assert match, f"no manager ID in row: {row}" return match.group(1)
[docs] def test_managers_index_table_renders(): """Check the manager index renders as one well-formed table. Six verdict groups, each opening on its own title row, wrapped managers linking to their documentation page and declined ones to their verdict, in the same columns. The table is rendered live at Sphinx build time by the ``{python:render}`` block in `docs/managers.md`, so there is no checked-in copy to compare against. """ table = _docs.managers_index_table() lines = table.splitlines() data = _docs._load_benchmark_toml() unsupported: dict[str, str] = data.get("unsupported", {}) competitor_data: dict[str, list[str]] = data.get("managers", {}) queued: dict[str, str] = data.get("queued", {}) anchors = _docs.unsupported_anchors() # The table is all there is: the prose lede restating the split the group # titles now show is gone. assert lines[0].startswith("|") # The leading column carries the brand marks and is headerless. header_pattern = r"\|\s+\| Manager\s+\| ID\s+\|\s+Support\s+\|\s+Platforms\s+\|" assert re.fullmatch(header_pattern, lines[0]) # The upstream readings moved to each manager's own card, and the # unmaintained flag folded into the shared Support column, so the index # must no longer spend columns on any of them. for header in ("Stars", "Last release", "Last commit", "Unmaintained"): assert header not in lines[0] # One table, not two: no blank line, the header and its separator appear # once, and every manager continues the same rows behind a title row per # state. assert lines.count("") == 0 assert len(lines) == ( 2 + len(_docs.SUPPORT_SCALE) + len(pool) + len(queued) + len(unsupported) ) # Split the body on its title rows. Each lays out like the rows it opens: # the state's glyph in the mark column, and beside it the state's own # SUPPORT_SCALE label, one wording for the legend and the group it titles. blocks: dict[str, list[str]] = {} titles = [] current: list[str] = [] for line in lines[2:]: marker = re.match( r'\|\s*(\S+)\s*\|\s*<span class="manager-group"[^>]*>(.+?)</span>', line, ) if marker: titles.append(marker.groups()) current = blocks.setdefault(marker.group(1), []) continue assert titles, "a manager row ahead of the first title row" current.append(line) assert titles == list(_docs.SUPPORT_SCALE.items()) # Every assessed manager sits in the group its own glyph puts it in, once, # alphabetically within that group. grouped: dict[str, list[str]] = {glyph: [] for glyph in _docs.SUPPORT_SCALE} for mid in sorted((*pool, *queued, *unsupported)): grouped[ _docs._bare_support_glyph(mid, unsupported, competitor_data, queued) ].append(mid) assert { glyph: [_row_manager_id(row) for row in rows] for glyph, rows in blocks.items() } == grouped supported_block = "\n".join( line for glyph in _docs.WRAPPED_GLYPHS.values() for line in blocks[glyph] ) declined_block = "\n".join( line for glyph, rows in blocks.items() if glyph not in set(_docs.WRAPPED_GLYPHS.values()) for line in rows ) unmaintained = 0 for mid, manager in pool.items(): assert mid not in unsupported, f"{mid} is both wrapped and unsupported" # Both the name and the identifier link to the manager's page, only # in the wrapped block. assert f"[{manager.name}](managers/{mid}.md)" in supported_block assert f"[`{mid}`](managers/{mid}.md)" in supported_block assert f"](managers/{mid}.md)" not in declined_block if manager.unmaintained: unmaintained += 1 if manager.logo: assert _docs.manager_logo(mid, inline=True) in supported_block assert unmaintained # The marker moved out of the ID cell into a column of its own, rendered # by the same helper the benchmark's `mpm` column uses. A wrapped # manager's glyph proves itself with a link to its source, never to the # unsupported page, and only the declined block ever shows â˜ ī¸/❌/🛟. assert supported_block.count("âš ī¸") == unmaintained assert "unsupported.md" not in supported_block for glyph in ("â˜ ī¸", "❌", "🛟"): assert glyph not in supported_block # A declined manager's Support cell matches the shared helper # benchmark_managers_table() also renders its own `mpm` column from, and # links to its verdict rather than to a page it does not have. for mid in unsupported: assert mid not in pool glyph_cell = _docs._support_glyph( mid, unsupported, anchors, competitor_data, queued ) assert glyph_cell in declined_block assert f"[`{mid}`](unsupported.md" in declined_block # Every placeholder was substituted by what it stands for: the artwork of a # mark, and the label of a group title. assert "%logo:" not in table assert "%group:" not in table # The table is the last thing rendered: no trailing prose. assert lines[-1].startswith("|")
[docs] def test_queued_todos_render(): """Check each `queued` entry becomes one `{todo}` carrying its blocker verbatim. `sphinx.ext.todo` collects them onto `docs/todolist.md`, the one page listing every 🚧 manager beside what it waits on, but only from a page that renders them. """ queued: dict[str, str] = _docs._load_benchmark_toml()["queued"] blocks = _docs.queued_todos().split("\n\n") assert len(blocks) == len(queued) for block, (mid, blocker) in zip(blocks, sorted(queued.items()), strict=True): assert block.startswith("```{todo}\n") assert block.endswith(f": {blocker}\n```") assert f"`{mid}`" in block.splitlines()[1] page = (PROJECT_ROOT / "docs" / "managers.md").read_text(encoding="UTF-8") assert "print(queued_todos())" in page
[docs] def test_brewfile_managers_table_renders(): """Check the Brewfile table lists exactly the managers that reach a dump. `--brewfile` is an option of one command, so the mapping belongs on that command's page and nowhere else: it used to be a prose enumeration that had gone stale, repeated by a row on every manager's own card. """ table = _docs.brewfile_managers_table() lines = table.splitlines() assert lines[0].startswith("| Manager") assert "Brewfile entry" in lines[0] exported = {mid: m.brewfile_entry_type for mid, m in pool.items()} expected = {mid: kind for mid, kind in exported.items() if kind} assert expected, "no manager declares a Brewfile entry type" rows = [line for line in lines[2:] if line.startswith("| [")] assert len(rows) == len(expected) for mid, kind in expected.items(): # Each manager links to its page, beside the keyword it writes. assert f"[`{mid}`](managers/{mid}.md)" in table assert any(f"`{mid}`" in row and f"`{kind}`" in row for row in rows) # A manager with no Brewfile mapping must not appear at all. for mid, kind in exported.items(): if not kind: assert f"[`{mid}`](managers/{mid}.md)" not in table # The fact lives here now, not on every manager's card. for mid in sorted(expected)[:3]: assert "Brewfile entry" not in _docs.manager_card(mid)
[docs] def test_manager_card_carries_upstream_readings(): """Check each sampled upstream reading reaches the card of its manager. The stars, newest release and newest commit used to be three columns of the index; they now belong to the page devoted to the tool they describe, so this follows them there rather than lapsing when the columns went. """ upstreams = _docs._manager_upstreams() assert upstreams, "no upstream readings sampled yet" for manager_id, record in upstreams.items(): assert manager_id in pool card = _docs.manager_card(manager_id) # Thousands separated, so a five-figure count is read at a glance. assert f"{record['stars']:,}" in card if record.get("commit"): assert record["commit"] in card # The release date is sampled but never shown: a project that cuts no # releases is indistinguishable from an abandoned one by that date # alone, which reported `cask` as dead since 2016 while it was being # committed to daily. `unmaintained` answers that question instead. assert "Last release" not in card # A manager whose upstream cannot be measured simply carries no such row, # rather than an empty one. unmeasured = set(pool) - set(upstreams) for manager_id in sorted(unmeasured)[:5]: assert "Upstream stars" not in _docs.manager_card(manager_id)
[docs] def test_upstream_badges_cover_every_forge(): """Check every sampled repository is hosted where a badge can read it. A forge missing from `UPSTREAM_FORGES` costs its managers the whole section, silently. Naming the host here turns that into one failing assertion pointing at the line to add. """ hosts = { urlparse(record["repo"]).netloc for record in _docs._manager_upstreams().values() if record.get("repo") } assert hosts, "no upstream repositories sampled yet" assert hosts <= set(_docs.UPSTREAM_FORGES), ( f"forges with no badge family: {sorted(hosts - set(_docs.UPSTREAM_FORGES))}" )
[docs] def test_manager_upstream_badges(): """Check the upstream section of every sampled manager holds live badges. Three invariants, each guarding a way the section silently goes wrong: the badges point at the forge family the repository is actually hosted on, the release-gated ones follow the sample rather than guessing (shields answers the same red error for an unreleased project and a missing one), and nothing here repeats a figure the infobox already states. """ upstreams = _docs._manager_upstreams() assert upstreams, "no upstream readings sampled yet" for manager_id, record in upstreams.items(): section = _docs.manager_upstream(manager_id) host = urlparse(record["repo"]).netloc family, instance_param = _docs.UPSTREAM_FORGES[host] badges = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", section) assert badges, f"{manager_id} renders no badge" for url in badges: assert url.startswith(f"{_docs.SHIELDS_URL}/{family}/") assert f"style={_docs.BADGE_STYLE}" in url # A self-hosted forge is only readable when its instance is named. if instance_param: assert f"{instance_param}={quote(f'https://{host}', safe='')}" in url release_badges = [url for url in badges if "release-date" in url] assert bool(release_badges) == ( record.get("release_source") == "release" and family == "github" ) # The stars and the newest commit stay facts of the card, stated once. assert "/stars/" not in section assert "last-commit" not in section
[docs] def test_matrix_blocks_in_sync(): """Check the compatibility matrices of the docs match the git tags. Each embedded matrix must match a fresh regeneration. Drift here means a release changed the Python classifiers or the click-extra requirement before repomatic's `update-docs` job refreshed the embedded blocks. Skipped when click-extra's `[sphinx]` extra (pulled by the `docs` dependency group) is missing, as in the hermetic unit-test environment. Without the full tag history (shallow clone, sdist build), regeneration leaves every block untouched and the test passes vacuously. """ try: from click_extra.sphinx import matrix # noqa: PLC0415 except ImportError: pytest.skip("needs the docs dependency group (click-extra[sphinx])") stale = matrix.update_matrix_blocks( (PROJECT_ROOT / "docs", PROJECT_ROOT / "readme.md"), check=True, ) assert not stale
[docs] def test_mirror_blocks_in_sync(monkeypatch): """Check the mirror blocks of the docs and readme match their generators. The `<!-- mirror-src -->` and `:mirror:` blocks must match a fresh regeneration. Drift here means a generator's output changed (a manager joined or left the pool) before repomatic's `update-docs` job refreshed the embedded blocks with `click-extra refresh-directives`. Regenerated with no `$TERM_PROGRAM` in the environment: a mirror is a checked-in markup file, so the padding it is compared against cannot be a function of the terminal the check happens to run in. Skipped when click-extra's `[sphinx]` extra (pulled by the `docs` dependency group) is missing, as in the hermetic unit-test environment. """ try: from click_extra.sphinx.python import update_mirror_blocks # noqa: PLC0415 except ImportError: pytest.skip("needs the docs dependency group (click-extra[sphinx])") # click-extra measures an emoji-presentation sequence (âš ī¸, â˜ ī¸) by the running # terminal's advance rather than by the Unicode tables, and which terminals # advance one column for it is `wcwidth`'s answer, not a roster kept in either # project. Under one of them the benchmark mirror regenerates a space short on # every row carrying such a glyph, so the committed table reads as stale. # Markup needs no terminal, and click-extra exempts it from `9.3.0` on, which # the `>=9` floor still lets a build resolve below, so the environment is # pinned here instead of left to the host: # https://github.com/kdeldycke/click-extra/commit/4f7d2785374f48319882bddc2ea8134383d191d9 monkeypatch.delenv("TERM_PROGRAM", raising=False) stale = update_mirror_blocks( (PROJECT_ROOT / "docs", PROJECT_ROOT / "readme.md"), check=True, ) assert not stale
[docs] def test_unsupported_page_matches_benchmark(): """Check `docs/unsupported.md` covers the benchmark's `unsupported` set. Exactly that set, with matching glyphs, sorted by anchor. The benchmark renders each `unsupported` manager as a glyph linking into that page for the reason, so the two must stay in lockstep. A manager listed on one side only produces either a link into a page that never explains it, or a section nothing points at. Both happened while the mechanism was being introduced, hence this test. The glyph must agree too: `â˜ ī¸` and `❌` are read side by side across the two pages, and `âš ī¸` is reserved for the *wrapped* managers of the index, so it must never leak onto a page listing what `mpm` refused to wrap. The `🛟` fallback marker is derived rather than written, so the page is held to what `unsupported_status()` computes from the competitor data. A family section carries one verdict for several tools, so every member is checked against that shared title: grouping tools whose glyphs differ would silently restate one tool's verdict as another's. """ toml_path = PROJECT_ROOT / "docs" / "benchmark.toml" benchmark = tomllib.loads(toml_path.read_text(encoding="UTF-8")) statuses = benchmark["unsupported"] competitors = benchmark["managers"] sections = _docs.unsupported_sections() assert sections, "no verdict sections found in docs/unsupported.md" anchors = [section.anchor for section in sections] assert anchors == sorted(anchors), ( "unsupported.md sections must be sorted by title, which sorts their " f"anchors: got {anchors}" ) assert len(anchors) == len(set(anchors)), ( "two unsupported.md sections slugify to the same anchor, so one of " "them is unreachable" ) ids = [mid for section in sections for mid in section.manager_ids] assert len(ids) == len(set(ids)), "unsupported.md covers a manager twice" assert set(ids) == set(statuses), ( "docs/unsupported.md and benchmark.toml's unsupported key disagree; " f"page-only: {sorted(set(ids) - set(statuses))}, " f"benchmark-only: {sorted(set(statuses) - set(ids))}" ) for section in sections: assert section.manager_ids, ( f"unsupported.md section {section.anchor!r} names no manager: a " "family title must list its members in the paragraph opening it" ) for mid in section.manager_ids: expected = _docs.unsupported_status( mid, statuses[mid], competitors.get(mid, []) ) assert section.glyphs == expected, ( f"unsupported.md section {section.anchor!r} shows " f"{section.glyphs!r} but benchmark.toml declares {mid!r} " f"{statuses[mid]!r}, which renders {expected!r}" ) # âš ī¸ marks a manager that is wrapped but unmaintained: it has no business # on the page cataloguing the tools that were never wrapped. page = (PROJECT_ROOT / "docs" / "unsupported.md").read_text(encoding="utf-8") assert "âš ī¸" not in page, ( "âš ī¸ is reserved for wrapped-but-unmaintained managers; " "docs/unsupported.md must use â˜ ī¸ or ❌" )
[docs] def test_unsupported_verdicts_cite_a_release(): """Check every verdict of `docs/unsupported.md` closes on its release. The release that published it, which the changelog must declare. The page invites a reassessment of any verdict it carries, and the release is what dates one: without it a reader cannot tell a decision taken two years ago from one taken last week. The stamp is hand-written, nothing else on the page holding that fact, so this is what stops a section copied from its neighbour claiming a release it was never published in. The version is all the page states. The `mpm-release` role resolves it to a link and a date at build time, so an unresolvable version renders as a docutils error on a page nobody rebuilt to look at: the same index the role reads is checked here against a fresh count of the changelog headings. Coupling a stamp to the changelog *bullet* announcing the decline is not available, and never was. A batch of declines ships as one `[mpm]` entry naming no ID (*Record 47 more unsupported managers, covering ...*), and the entry introducing the page named none of the ten tools it carried. The release is what the page and the changelog do share, so it is what gets checked. """ changelog = PROJECT_ROOT.joinpath("changelog.md").read_text(encoding="UTF-8") heading = re.compile(r"^## \[`([^`]+)` \(([^)]+)\)\]", re.MULTILINE) # A development section is titled for the version it will ship as, so its # `.devN` suffix comes off: a verdict published today stamps `8.0.0`, and # the page announces it a release ahead of the tag as the changelog does. dates = { re.sub(r"\.dev\d+$", "", version): date for version, date in heading.findall(changelog) } assert dates, "no release heading found in changelog.md" upcoming = re.sub(r"\.dev\d+$", "", __version__) assert upcoming in dates, f"changelog.md declares no {upcoming!r} section" # Recounted from the file rather than read off the production index, so a # parser dropping a release cannot hide behind the check it feeds. index = _docs.changelog_releases() assert {version: date for version, (date, _url) in index.items()} == dates # No verdict can predate the release that first published the page itself. # The changelog lists its releases newest first, so the ones that could # have carried a verdict are the slice down to that release. oldest = "7.6.0" assert oldest in dates, f"changelog.md declares no {oldest!r} release" published = tuple(dates)[: tuple(dates).index(oldest) + 1] sections = _docs.unsupported_sections() assert sections for section in sections: assert section.release, ( f"unsupported.md section {section.anchor!r} carries no " "``Declined in {mpm-release}`<version>`.`` line: every verdict " f"names the release that published it, {upcoming!r} for a new one" ) assert section.release in dates, ( f"unsupported.md section {section.anchor!r} is declined in " f"{section.release!r}, a version no changelog.md release heading " f"declares, so the `mpm-release` role cannot render it; the " f"release in development is {upcoming!r}" ) assert section.release in published, ( f"unsupported.md section {section.anchor!r} is declined in " f"{section.release!r} ({dates[section.release]}), which predates " f"{oldest!r}, the release that first published the page" ) # One stamp per section, and each closes its own: the line is page # structure rather than prose, so it reads the same everywhere. page = PROJECT_ROOT.joinpath("docs", "unsupported.md").read_text(encoding="UTF-8") stamps = tuple(_docs.DECLINE_STAMP.finditer(page)) assert len(stamps) == len(sections), ( f"docs/unsupported.md carries {len(stamps)} decline stamps for " f"{len(sections)} verdict sections: a section has two, or one sits " "under a heading carrying no verdict" ) for stamp in stamps: trailing = page[stamp.end() :].lstrip("\n") follower = trailing.splitlines()[0] if trailing else "" assert trailing.startswith("## ") or not trailing, ( f"the {stamp[0]!r} line must close its section, but {follower!r} follows it" ) # An unregistered role renders as its own literal braces on the published # page. Only a build exercises the role itself; this is what catches it # going missing from the configuration. conf = PROJECT_ROOT.joinpath("docs", "conf.py").read_text(encoding="UTF-8") assert 'app.add_role("mpm-release"' in conf, ( "docs/conf.py must register the `mpm-release` role the decline stamps " "of docs/unsupported.md are written with" )
[docs] def test_unsupported_wikipedia_references(): """Check the Wikipedia references of `docs/unsupported.md` read one way. A verdict section links the English Wikipedia articles of the tools it covers on a single line, right before the stamp closing it, as `Wikipedia: [title](url), [title](url) and [title](url).` """ page = PROJECT_ROOT.joinpath("docs", "unsupported.md").read_text(encoding="UTF-8") # A title links the tool's home page, and an article is not one. titles = re.findall(r"^## .*wikipedia\.org.*$", page, re.MULTILINE) assert not titles, f"titles linking Wikipedia as a home page: {titles}" link = re.compile(r"\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)") references = re.findall(r"^Wikipedia: .+$", page, re.MULTILINE) assert references, "docs/unsupported.md links no Wikipedia article" for line in references: links = link.findall(line) assert links, f"{line!r} links nothing" urls = [url for _label, url in links] for url in urls: check_wikipedia_url(url) assert len(urls) == len(set(urls)), f"{line!r} links an article twice" rendered = [f"[{label}]({url})" for label, url in links] listed = ( rendered[0] if len(rendered) == 1 else f"{', '.join(rendered[:-1])} and {rendered[-1]}" ) assert line == f"Wikipedia: {listed}.", ( f"{line!r} must read `Wikipedia: [title](url), [title](url) and " "[title](url).`" ) follower = page.partition(f"{line}\n\n")[2].partition("\n")[0] assert _docs.DECLINE_STAMP.fullmatch(follower), ( f"{line!r} must sit right before its section's decline stamp" )
[docs] def test_unsupported_anchors_match_docutils(): """Check the anchors linked from the benchmark are the ones Sphinx emits. `_heading_slug()` reimplements what `docs/conf.py` pins `myst_heading_slug_func` to, because `docutils` only reaches this project through the docs dependency group while the benchmark table also renders under the test group. That copy is only safe while the two agree, and a disagreement is invisible: the link renders, resolves to nothing, and drops the reader at the top of the page. """ try: from docutils.nodes import make_id # noqa: PLC0415 except ImportError: pytest.skip("needs the docs dependency group (click-extra[sphinx])") page = (PROJECT_ROOT / "docs" / "unsupported.md").read_text(encoding="utf-8") titles = [line[3:] for line in page.splitlines() if line.startswith("## ")] assert titles for title in titles: # `make_id` slugifies rendered text, so strip the markdown first. plain = re.sub(r"\[`?([^`\]]+)`?\]\([^)]*\)", r"\1", title).replace("`", "") assert _docs._heading_slug(title) == make_id(plain), ( f"unsupported.md heading {title!r} slugifies differently in " "_docs._heading_slug() and docutils.nodes.make_id()" )
[docs] def test_cooldown_support_statuses_known(): """Check every row of the cooldown support table carries a legend glyph. The "Supported managers" table of `docs/cooldown.md` is hand-curated and reused on the per-manager pages, so a typo'd or invented status glyph would travel silently. Only the five glyphs its own legend defines are allowed. """ legend = {"✅", "🔜", "🚧", "❌", "➖"} rows = _docs._cooldown_table("## Supported managers") seen = set() for cells in rows: # Skip the header and separator lines, which carry no manager link. if not cells[0].startswith("| ") and not cells[0].startswith("["): continue glyph = cells[1][:2].strip() assert any(cells[1].startswith(g) for g in legend), ( f"cooldown row {cells[0]!r} opens with {glyph!r}, " f"which is not one of the legend glyphs {sorted(legend)}" ) seen.add(next(g for g in legend if cells[1].startswith(g))) assert seen, "no manager rows parsed from the cooldown support table"
[docs] def test_benchmark_operations_rows_have_support(): """Check no row of the benchmark's Operations table is entirely blank. A row no tool supports carries no information and is usually a leftover from a competitor column that was dropped: the `Latest filter`, `Reshim` and `Import from external tool` rows outlived the `mise`/`asdf` columns they were written for. The table is hand-maintained, so nothing else catches it. """ page = (PROJECT_ROOT / "docs" / "benchmark.md").read_text(encoding="utf-8") section = page.partition("## Operations")[2].partition("\n## ")[0] rows = [line for line in section.splitlines() if line.startswith("|")] assert len(rows) > 2, "Operations table not found" for line in rows[2:]: cells = [cell.strip() for cell in line.strip("|").split("|")] label, tools = cells[0], cells[1:] assert any("✅" in cell or "đŸĒĢ" in cell for cell in tools), ( f"Operations row {label!r} shows no support from any tool; " "drop the row or fill in the tool that provides it" )
[docs] def test_cooldown_support_table_covers_the_pool(): """Check the cooldown support table holds one row per pool manager. The "Supported managers" table of `docs/cooldown.md` is hand-curated, and `manager_cooldown()` reuses each manager's row on its own page. A manager missing from the table silently degrades that page to a "not yet assessed" line, which is how `pikaur`, `trizen` and `dkp-pacman` all shipped uncovered, and one listed twice would resolve to whichever row comes first: the mapping must partition the pool, exactly like the retraction table below. """ rows: dict[str, int] = {} for cells in _docs._cooldown_table("## Supported managers"): # Key on linked code spans: every id cell links to its manager page, # while the header row's bare `mpm` span carries no link. for mid in re.findall(r"\[`([^`]+)`\]", cells[0]): rows[mid] = rows.get(mid, 0) + 1 missing = sorted(set(pool.all_manager_ids) - set(rows)) assert not missing, f"No cooldown support row covers: {', '.join(missing)}." unknown = sorted(set(rows) - set(pool.all_manager_ids)) assert not unknown, f"Support rows name unknown managers: {', '.join(unknown)}." duplicated = sorted(mid for mid, count in rows.items() if count > 1) assert not duplicated, ( f"Several cooldown support rows cover: {', '.join(duplicated)}." )
[docs] def test_retraction_table_well_formed(): """Check the retraction table partitions the pool and travels intact. The "Retraction paths by registry" table of `docs/cooldown.md` is hand-curated, and `manager_cooldown()` reuses each row on the pages of the managers listed in it. Two invariants follow. A manager missing from the table silently loses the section on its page and one listed twice would resolve to whichever registry comes first, so the mapping must be a partition of the pool. And a reused cell renders from `docs/managers/`, one directory below `docs/cooldown.md`, so any relative target or same-page anchor it carries would resolve against the manager page instead: the reused cells must link absolutely or not at all. """ registries: dict[str, list[str]] = {} relative = [] for cells in _docs._cooldown_table("## Retraction paths by registry"): for mid in re.findall(r"\[`([^`]+)`\]", cells[1]): registries.setdefault(mid, []).append(cells[0]) # The managers cell (index 1) stays behind on the cooldown page: only # the registry, retraction and publish-date cells get reused. for cell in (cells[0], cells[2], cells[3]): relative += [ target for target in re.findall(r"]\(([^)]+)\)", cell) if not target.startswith("https://") ] assert set(registries) == set(pool.all_manager_ids) assert not {mid: rows for mid, rows in registries.items() if len(rows) > 1} assert not relative
[docs] @all_managers def test_retraction_status_reuses_table_row(manager): """Check each manager's page surfaces its own registry row verbatim. A registry documenting neither a retraction path nor a publish date leaves the row alone: it renders as a plain line rather than a one-item list. """ row = _docs._retraction_status(manager.id) # No row is a legitimate return for an unknown ID, but never for a pool # manager: test_retraction_table_well_formed holds the table to a partition. assert row, f"No retraction row covers {manager.id}." registry, withdrawal, publish_date = row section = _docs.manager_cooldown(manager.id) cells = [ cell for cell in (withdrawal, publish_date) if cell not in _docs.EMPTY_CELLS ] bullet = "- " if cells else "" assert f"{bullet}Registry: {registry}" in section for label, cell in (("Retraction", withdrawal), ("Publish date", publish_date)): line = f"- {label}: {cell}" assert (line in section) is (cell not in _docs.EMPTY_CELLS)
SKILL_SPEC_FIELDS = frozenset({ "allowed-tools", "compatibility", "description", "license", "metadata", "name", }) """The six frontmatter fields the [Agent Skills spec](https://agentskills.io/specification) defines.""" SKILL_CLAUDE_CODE_EXTENSIONS = frozenset({"argument-hint"}) """The only non-spec frontmatter field a skill may carry. Mirrors the set `kdeldycke/repomatic` pins in its own `tests/test_skills.py`: no spec field expresses an autocomplete hint, and the key degrades to a no-op wherever it is not understood. Do not grow this set. In particular there is no `model:` (the recommended model rides in `compatibility`) and no `disable-model-invocation:`: every skill is model-invocable by design, and what it may actually do is gated by the permission layer, not by frontmatter. """ SKILL_MODEL_HINT = re.compile(r"Recommended model: \w+\.") """Shape of the model recommendation carried by `compatibility`.""" def _skill_frontmatter(path): """Split a `SKILL.md` into its parsed frontmatter and its body lines.""" lines = path.read_text(encoding="utf-8").split("\n") assert lines[0] == "---", f"{path} does not open with a frontmatter fence" closing = lines.index("---", 1) return safe_load("\n".join(lines[1:closing])), lines
[docs] def test_add_manager_skill_frontmatter(): """Check the bundled skill conforms to the Agent Skills specification. The frontmatter contract is the one `kdeldycke/repomatic` enforces on its own skills, kept here so this repository's single local skill cannot drift away from the upstream convention it was aligned to. A comma-separated `allowed-tools` is the trap worth pinning: Claude Code accepts it, so the skill keeps working locally while silently failing the spec, which wants a space-separated string. """ skill_dir = PROJECT_ROOT / ".claude" / "skills" / "add-manager" meta, _lines = _skill_frontmatter(skill_dir / "SKILL.md") unknown = set(meta) - SKILL_SPEC_FIELDS - SKILL_CLAUDE_CODE_EXTENSIONS assert not unknown, f"unknown frontmatter fields: {sorted(unknown)}" assert meta["name"] == skill_dir.name, "name must match its directory" assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", meta["name"]) assert 0 < len(meta["description"]) <= 1024 assert len(meta["compatibility"]) <= 500 assert SKILL_MODEL_HINT.search(meta["compatibility"]), ( f"compatibility names no recommended model: {meta['compatibility']!r}" ) assert "," not in meta["allowed-tools"], ( f"allowed-tools must be space-separated: {meta['allowed-tools']!r}" )
[docs] def test_add_manager_skill_include_offset(): """Check `docs/add-new-manager.md` slices the skill at its human body. The page is a bare `{include}` of the skill, so the two files share one source and can only disagree through the hard-coded `:start-line:`. That offset silently absorbs any line added to or removed from the skill's preamble: too low republishes the frontmatter, the skill's own `#` heading or its `$ARGUMENTS` line, and too high eats the opening paragraph. Neither fails a docs build, which is why it is pinned here instead. """ skill = PROJECT_ROOT / ".claude" / "skills" / "add-manager" / "SKILL.md" _meta, lines = _skill_frontmatter(skill) # Walk past the frontmatter, then drop every leading paragraph that is # addressed to Claude alone: the `#` title the page supplies itself, and # the `$ARGUMENTS` line that means nothing to a reader of the rendered page. cursor = lines.index("---", 1) + 1 while cursor < len(lines) and ( not lines[cursor] or lines[cursor].startswith("# ") or "$ARGUMENTS" in lines[cursor] ): cursor += 1 page = (PROJECT_ROOT / "docs" / "add-new-manager.md").read_text(encoding="utf-8") declared = re.search(r"^:start-line: (\d+)$", page, re.MULTILINE) assert declared, "docs/add-new-manager.md declares no :start-line:" assert int(declared.group(1)) == cursor, ( f"docs/add-new-manager.md must declare `:start-line: {cursor}` to open " f"on {lines[cursor]!r}" ) rendered = lines[cursor:] assert "$ARGUMENTS" not in "\n".join(rendered), ( "$ARGUMENTS leaked into the rendered page: keep it in the preamble" ) assert not [line for line in rendered if line.startswith("# ")], ( "the rendered slice carries a second H1: the page supplies its own" )