# 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)
# 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_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_verbatim_card_links_are_rooted():
"""A sphinx-design `:link:` is emitted verbatim, so a local one is rooted.
Sphinx rewrites the paths it owns, per page: the `:img-top:` of the same
card becomes the `../_images/âĻ` a directory-deep `dirhtml` page needs. A
`:link:` gets none of that, so a relative `assets/âĻ` resolved against the
page's own URL and reached for `/bar-plugin/assets/âĻ`, which the site does
not publish. Same failure, and same fix, as `manpages_url` in `conf.py`:
the site is only ever served from the domain root, which is what makes the
leading slash safe. Nothing else catches it, `linkcheck` visiting external
URLs alone.
"""
assets = PROJECT_ROOT.joinpath("docs", "assets")
for page in sorted(PROJECT_ROOT.joinpath("docs").glob("*.md")):
content = page.read_text(encoding="UTF-8")
for target in re.findall(r"^:link: (\S+)$", content, re.MULTILINE):
if urlparse(target).scheme:
continue
assert target.startswith("/"), (
f"{page.name} links {target}, resolved against the page's own "
"URL rather than the site root."
)
if target.startswith("/_images/"):
assert assets.joinpath(Path(target).name).is_file(), (
f"{page.name} links {target}, which no asset backs."
)
[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]
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_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_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_links_never_wrap_inside():
"""Check the stylesheet keeps each card link whole, icon and label together.
The card marks its links with a class the stylesheet alone gives meaning to,
so the two are held together here: a renamed class would leave every link
free to wrap between its icon and its label again.
"""
css = (PROJECT_ROOT / "docs" / "_static" / "custom.css").read_text(
encoding="UTF-8",
)
rule = css.partition(".manager-card a.manager-link {")[2].partition("}")[0]
assert "white-space: nowrap;" in rule
assert "{.manager-link}" in _docs.manager_card("brew")
[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)
[docs]
def test_docstrings_carry_no_angle_bracket_link_destination():
"""The `](<url>)` destination form renders as broken markup.
See `ANGLE_LINK_DEST` for the converter limitation and the rewrite to apply.
"""
hits = [
f"{path.relative_to(PROJECT_ROOT)}:{lineno}: {match.group()}"
for path, lineno, doc in _prose_docstrings()
for match in ANGLE_LINK_DEST.finditer(doc)
]
assert not hits, "Angle-bracketed link destinations:\n" + "\n".join(hits)
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]
@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"
)