Contribution guide¶
Candidates for a new package manager come from mpm’s own coverage map. The Benchmark holds one row per tool any comparable wrapper drives, so a blank cell in its mpm column marks a tool nobody has assessed yet: that is the worklist. Unsupported managers is the other half of the map, recording each tool already declined and the reason, which is worth checking before proposing one.
Document a new package manager¶
Not a coder? No problem.
You can still provide invaluable information. Open a new issue and fill in the form with raw output of CLI calls to your manager. Armed with this critical data, a contributor or maintainer can attempt a blind implementation. From there we’ll collectively iterate until we reach a usable level.
This is often the best approach, as it is sometimes hard to create the same environment as the users.
Code support for a new package manager¶
If you’re a Python developer, see the Add a new package manager guide for the full implementation checklist: module structure, registration, testing, and documentation updates.
claude.md file¶
Project-specific guidance for working in this repository. The generic coding conventions load from the maintainer’s machine configuration and are deliberately not duplicated here: this file carries only what is specific to mpm.
Project overview¶
Meta Package Manager (mpm) is a CLI that wraps multiple package managers (Homebrew, apt, pip, npm, etc.) behind a unified interface. It can list, search, install, upgrade, and remove packages across all supported managers simultaneously, and snapshot the whole inventory to a single file that restores it on another machine.
Upstream conventions¶
This repository uses reusable workflows from kdeldycke/repomatic and follows the conventions established there. The generic conventions used to be projected into this file by repomatic’s retired agent component; they now live with the maintainer, and this file keeps only the mpm-specific half.
Contributing upstream: If you spot inefficiencies, improvements, or missing features in the reusable workflows, propose changes via a pull request or issue at kdeldycke/repomatic.
Stability policy¶
This project more or less follows Semantic Versioning.
Which boils down to the following these rules of thumb regarding stability:
Patch releases:
0.x.n→0.x.(n+1)upgradesAre bug-fix only. These releases must not break anything and keep backward-compatibility with
0.x.*and0.(x-1).*series.Minor releases:
0.n.*→0.(n+1).0upgradesIncludes any non-bugfix changes. These releases must be backward-compatible with any
0.n.*version but are allowed to drop compatibility with the0.(n-1).*series and below.Major releases:
n.*.*→(n+1).0.0upgradesMake no promises about backwards-compatibility. Any API change requires a new major release.
Unmaintained managers: managers whose
unmaintainedflag is setAre exempt from the rules above. A manager is flagged
unmaintainedwhen its upstream project is officially retired or we infer it is abandoned: archived on its forge, left without a release or commit for years (~3+), formally superseded by a successor, or part of a discontinued platform. A superseded-but-still-maintained tool (like a compatibility alias its upstream keeps shipping, such asyumfrontingdnf) is not unmaintained.The commitment is to keep the wrapper for as long as that stays cheap: an unmaintained manager may still be removed, in part or in full, in any release and without notice, once keeping it working becomes too burdensome. Each flag is documented via the manager’s
unmaintained_message(a markdown block rendered into the docs), and unmaintained managers are hidden from default selection and kept out of the functional and integration test matrices to save CI resources. An upstream that is merely slowing down does not earn the flag: it carries an informational maintenance note instead. Both render on the manager’s own page. See theunmaintainedattribute inmeta_package_manager/manager.pyfor the full policy.Being flagged is a different axis from being unsupported: an unmaintained manager is still wrapped and usable, whereas the tools in
docs/unsupported.mdwere never wrapped at all.
Cooldown on every install¶
mpm --cooldown applies the same idea to a different subject, and the two are easy to conflate here. That flag is a user-facing feature, gating the packages mpm installs on the user’s machine, and docs/cooldown.md is its documentation. This section covers what CI resolves onto a runner while building mpm itself. A comment or changelog entry naming one should not read as the other.
The product feature’s vocabulary is settled; reuse it rather than coining near-synonyms. The gate has two axes: the window (the release age itself, period in configuration) and the posture (what happens to managers that cannot enforce it, policy), spelled by the single --cooldown union and the [mpm.cooldown] table. Managers are gateable or ungateable depending on whether they carry a native release-age mechanism. Never write “unsupported managers” in this sense: that phrase belongs to the tools mpm declined to wrap (docs/unsupported.md) and to platform support, and the collision is what the 8.0.0 rework removed. Strength words (strict, soft, full, partial) are kept out of the keyword set because they read as a weaker window rather than a narrower scope; the shipped keywords are enforce / best-effort / off, and the default posture is fail-closed by design.
The uv and npm knobs and their per-package exemptions are the ones in § Per-ecosystem knobs of the machine-wide instructions. For every other package manager, docs/cooldown.md is the inventory, and it is this project’s own: which managers enforce a cooldown natively, which have support proposed upstream, which have none, and which are N/A because their archive already stages releases on its own.
Documented exemptions¶
Three installs deliberately bypass the window. Two are per-package and never widen to the rest of the tree; the third is a whole workflow, and says why it has to be.
The upstream toolkit’s own pin.
repomaticruns from a pin that moves in lockstep with theuses:refs pointing at it, so a release must be installable the minute it is published. Everyuvxcall carrying it passes--exclude-newer-package repomatic=P0Dbeside the pin.A fresh click-extra or extra-platforms, on the day it ships. Both share a maintainer with
mpmand evolve in lockstep: mostmpmreleases raise one of their floors to pick up what that library just shipped, so a week-long wait before the release can even resolve stalls the work rather than protecting it.[tool.uv] exclude-newer-packagenames the package with the midnight following its upload, an absolute timestamp covering that release and nothing published after it. The entry is transient, and repomatic’ssync-uv-lockprunes it once that release ages past the window, which is what keeps the exemption from quietly becoming a standing one.The
tests-install.yamlworkflow. Its subject is the freshly published artifact, so a cooldown would make the question it exists to answer unanswerable. It declaresUV_EXCLUDE_NEWER: P0Dat workflow level rather than relying on uv’s default, so the opt-out reads as a decision.
A fourth exemption is a bug until proven otherwise. Anything claiming one carries a comment naming what breaks without it, and the narrowest scope that still works: a package, not a job; a job, not a workflow.
Build status¶
Commands¶
Setup environment¶
Check out latest development branch:
$ git clone [email protected]:kdeldycke/meta-package-manager.git
$ cd ./meta-package-manager
$ git checkout main
Install package in editable mode with all development dependencies:
$ python -m pip install uv
$ uv venv
$ source .venv/bin/activate
$ uv sync --all-extras --all-groups
Test mpm development version¶
After the steps above, you are free to play with the bleeding edge version of mpm:
$ uv run -- mpm --version
(...)
mpm, version 4.13.0
Unit-tests¶
Run unit-tests with:
$ uv sync --group test
$ uv run -- pytest
Which should be the same as running non-destructive unit-tests in parallel with:
$ uv run pytest --numprocesses=auto --skip-destructive
Destructive tests mess with the package managers on your system. The safe local invocation runs them sequentially:
$ uv run pytest --numprocesses=0 --skip-non-destructive --run-destructive
CI parallelizes them instead, mirroring mpm’s own concurrency model: tests/conftest.py stamps every destructive test with an xdist_group derived from SHARED_LOCK_FAMILIES in meta_package_manager/dispatch.py, so --dist=loadgroup keeps managers contending for one backend lock serial on a single worker while independent families run at once. The cross-manager tests (destructive_all_managers marker) drive every available manager in one invocation, which no grouping can isolate, and keep a sequential CI step of their own. The sequential command stays the local recommendation: it cannot interleave sudo prompts and spares a workstation the parallel load.
Note for downstream packagers¶
The canonical guidance for distribution packagers (test-suite layers, /homeless-shelter auto-skip, ignore-globs for writable-$HOME builders, dependency constraints, per-channel build instructions) lives in docs/packaging.md, published at https://mpm.run/packaging/. Packaging specs (packaging/nix/, packaging/alpine/, and their upstream submissions) must reference that URL, never this file.
Keep the comments in those specs tight. Their audience is each channel’s own maintainers, who already know their build-sandbox conventions (the /homeless-shelter auto-skip, standard make_check / test-phase behavior): drop those, drop doc-link pointers, and collapse verbose per-dependency breakdowns to a single line. Keep only non-obvious, spec-specific rationale: a live workaround still needed, or why a particular test or dependency is excluded. When unsure, favor the tighter comment. This holds for both the in-repo packaging/* specs and their downstream branch copies.
Type checking¶
$ uv run --group typing mypy meta_package_manager
Documentation¶
Build Sphinx documentation locally:
$ uv sync --group docs
$ uv run -- sphinx-build -b html ./docs ./docs/html
The docs group declares requires-python = ">= 3.14" of its own, above the project’s 3.10 floor, so a venv built on an older interpreter resolves the group to nothing and sphinx-build is simply absent rather than broken. Narrowing it is what lets the documentation dependencies carry flat version floors: see the comment on dependency-groups.docs in [tool.uv].
The generation of API documentation is covered by a dedicated workflow.
Documentation requirements¶
Example data¶
Do not reach for software-engineering or packaging vocabulary for a placeholder, and never invent a plausible-looking package or manager name: this project’s whole domain is package metadata, so a made-up foo-lib 1.2.3 in a docstring is indistinguishable from a real fixture and will eventually be read as one.
The exception is the material that must be real to be correct: the [samples] fixtures of the bundled TOML definitions and the shell-session blocks harvested into the docstring corpus are captured CLI output, held to byte-accuracy by test_documented_output_still_parses. Those are not examples, they are data.
Changelog and readme updates¶
readme.md: Update relevant sections when adding/modifying public API, classes, or functions.
Changelog scope tags. Every bullet opens with a comma-separated [scope] tag, alphabetically sorted and deduplicated, drawn from the pool manager IDs plus the platform IDs and mpm, bar-plugin, gnome-shell. test_changelog enforces that vocabulary, and it is not cosmetic: scope_changelog() indexes the tags to build the release-history section of every manager page, and of the SwiftBar and GNOME Shell pages from the bar-plugin and gnome-shell scopes. Three consequences. A tag naming a manager must describe mpm’s support for that manager, so work on mpm’s own downstream package for a channel that shares a manager’s name (Guix, Nix, MacPorts, Alpine) is scoped [mpm] like the rest of the packaging work, never to the manager whose page it would otherwise land on. And a new manager needs its changelog entry, since test_manager_changelog_entries asserts every pool manager has one. And a bullet must read on every page its tags select: a multi-scope entry states only what is true for all of them, naming no other subject’s history, while the tags must cover every subject the change reached. Check them against the files the commit touched, which is not mechanically checkable: 30fa10ce shipped one version-diff fix to both version.py and mpm.js yet tagged only [bar-plugin], hiding it from the extension’s page.
Benchmark page (docs/benchmark.md)¶
The benchmark compares mpm against related tools. It mixes one generated table with several hand-maintained ones, and its cells follow strict evidence rules.
Generated vs hand-maintained. Only the “Package manager support” table is generated: it renders live at Sphinx build time through the {python:render} block in docs/benchmark.md, which calls benchmark_managers_table() from meta_package_manager/_docs.py, fed by docs/benchmark.toml; its competitor set is the BENCHMARK_COMPETITORS tuple. Every other table (Features, Operations, OS, Distribution, Activity, Popularity, Metadata) is edited by hand. The block carries the :mirror: flag: a generated copy of the table is checked in right below the fence, between <!-- mirror -->/<!-- mirror-end --> markers, so it is reviewable in raw diffs and renders on GitHub. Never hand-edit the mirrored region: click-extra refresh-directives (run by repomatic’s update-docs job, or by hand from the repository root) regenerates it. Sphinx builds keep rendering the live output in memory and never read the mirror, so the published table cannot drift even when the checked-in copy is stale; the mpm-column ✅ links (class source-line anchors) are computed at render time, so the mirror legitimately churns whenever manager source lines shift. test_benchmark_table_renders guards the generator against crashes and structural regressions.
Cell glyphs. ✅/❌ are shared by the docs’ comparison and capability tables: the benchmark tables, the SBOM page’s coverage matrix and tool-comparison table, the cooldown support table and the augmentations table. Never backtick-quote a glyph, in a table cell or in prose: a glyph is not an identifier, the backticks render as a code span around a pictograph, and in the benchmark’s mpm column ✅ and ⚠️ are links, which a code span would flatten.
A dead upstream gets two glyphs, and the split is the point — it encodes whether mpm ships code for the tool:
⚠️ — wrapped, but at risk. The upstream is abandoned and the manager carries the
unmaintainedflag, yet it stays wrapped and usable. Marks theSupportcolumn of the manager index and the benchmark’smpmcolumn alike, replacing the ✅ that manager would otherwise get, plus the same fact inreadme.md’s operation matrix.☠️ — never wrapped. The upstream is dead, so
mpmdeclined to write the manager at all. Closes the section title ofdocs/unsupported.mdand marks the manager index’sSupportcolumn and the benchmark’smpmcolumn alike (see theunsupportedkey below), alongside ❌ for a live tool declined on its own merits.
The benchmark’s mpm column and the manager index’s Support column are therefore both a six-state scale, and the families never mix: ✅/⚠️ link to the implementing class, ☠️/❌ to the decision not to write one, and 🚧 links to nothing, having neither yet. Only the benchmark ever shows a blank cell, since the manager index lists nothing it has not assessed.
🚧 — assessed as a candidate, not wrapped yet. The manager clears the vetting ladder and waits on one thing: a host the assessing machine is not, a date a dead-upstream recheck waits on, or the project scope
mpmdoes not implement yet. That last group is why this glyph replaced the blank cell the project-scoped ecosystems used to keep:❌overstated them, but a blank one said nothing at all. Each blocker is written down per manager in thequeuedtable ofdocs/benchmark.toml, and landing the wrap is what removes the entry. A blank cell now means one thing only, that nobody has assessed the tool.
A manager cannot be in two of the three sources at once: the pool, queued and unsupported partition everything assessed, which is what lets the support bar and its legend count the population exactly once.
Never swap one for the other: a reader scanning for something they can still install today needs ⚠️ to mean “works, may go away” and ☠️ to mean “was never there”. Two tables add glyphs that do not travel: the benchmark’s 🟡 for coarse support a competitor cannot invoke in isolation, and the cooldown table’s 🔜 (gate shipped upstream, not yet plugged into mpm), 🚧 (proposed upstream) and ➖ (not applicable). Only the dense per-manager operation grids keep plain ✓: readme.md’s operation matrix and each manager page’s own operations table. The bar plugin’s ⚠️ is unrelated, counting runtime errors rather than upstream health. The evidence-link discipline below is benchmark-specific.
✅ — supported. The
mpm✅ is always a link: to the manager class’s source line in the generated table, to the feature’s user documentation in the Features table, where the row’s label carries that same target so both halves of the row lead to the same page. A competitor’s ✅ links to whatever proves the support — its documentation, config example, CLI declaration, or the source line implementing it — and stays a bare glyph only when the research turned up nothing citable.❌ — not supported, and only ever written with a link to explicit, verifiable evidence that the project lacks or rejects the feature: an issue/PR closed not-planned, a maintainer “out of scope” / “won’t add” comment, a still-open unaddressed feature request, or an official doc/man-page stating the limitation. Absence of the feature is never sufficient — if no citable source exists, leave the cell blank. Verify every URL (
gh issue view,gh api, or WebFetch) and keep the exact supporting quote before committing the link; prefer a precise#issuecomment-<id>anchor when a maintainer states the position. This mirrors the “Concurrent multi-PM execution” row.🟡 — coarse/bundled support the competitor cannot invoke in isolation (e.g., topgrade’s
--only shellrunning every shell-plugin manager at once), also with an evidence link.
docs/benchmark.toml has five alphabetically-sorted keys: managers (which competitor supports each manager), homepages (URLs for non-pool managers only), coarse_support ({manager: {competitor: url}}), refused ({manager: {competitor: url}} for competitors that explicitly declined a manager mpm wraps), and unsupported ({manager: status} for managers mpm itself declined). test_benchmark_toml_well_formed enforces the shape plus the no-orphan and no-conflict invariants (a (manager, competitor) pair cannot be in both managers and refused; an unsupported manager must have a managers row and must not be in the pool). TOML over YAML is a deliberate choice, not just a rename: tomllib rejects a duplicate manager-id row as a parse error, where yaml.safe_load used to silently keep the last one.
The table is a coverage map, so a manager no tool wraps still earns a row. A competitor’s backend that mpm lacks is exactly what the table exists to surface: leaving it out hides the gap. By the same logic a retired tool is never dropped from the table — a competitor that still drives a dead tool is a fact about the competitor. The mpm column then distinguishes the two kinds of absence, which is what unsupported is for — ❌ links to the decision in docs/unsupported.md, while a blank cell means the tool was never assessed. Only settled decisions belong in unsupported: a tool framed as a not yet (the project-scoped ecosystems) keeps its blank cell, since ❌ would overstate it. The page lives once in _docs.py’s UNSUPPORTED_DOCS_URL and the section anchor comes from unsupported_anchors(), never repeated per manager in the TOML.
docs/unsupported.md is the user-facing record, not the reasoning. It carries one section per excluded tool, the reason for it and the release that published it, and nothing else. The title is the tool as a linked code span followed by its glyphs ([`paq`](https://github.com/savq/paq-nvim) ❌ 🛟), which slugifies to the bare manager ID: that anchor is what the benchmark, the other pages, the code comments and the docstrings link to, so https://mpm.run/unsupported/#paq cites a decision from anywhere. Tools sharing a verdict word for word are grouped into one family section instead, titled after the family and naming its members in the paragraph that opens it, which is where unsupported_sections() reads them from when the title holds no ID. Each section closes on a Declined in {mpm-release}`X.Y.Z`. line naming the release that first published the verdict. The mpm-release role of docs/conf.py renders it as a link to that release plus its date, so neither is ever written into the page; DECLINE_STAMP records why. A family shares one stamp, so a tool declined into a verdict that already stood earns its own section unless the family’s release is still true of it. test_unsupported_verdicts_cite_a_release holds every stamp to a release the changelog declares, no older than 7.6.0, which first published the page. Sections are sorted by title, and test_unsupported_page_matches_benchmark holds the whole contract: the ordering, the coverage against benchmark.toml, and every member of a family agreeing on the glyphs their shared title shows. Every guideline behind it — why a dead upstream or a registry-less wrapper is disqualified, how to pick the live end of a lineage, the unattended-entry-point test, the three requirements enforced in code and their escape hatches — lives in the add-manager skill, where someone deciding whether to wrap a tool will actually read it. Keep it that way: the page is a record, and rationale added to it belongs in the skill instead.
Two groups of benchmark rows are easy to misread, and neither is a refusal. Competitors like topgrade also drive system updaters, dotfile managers and single-application updaters, which are outside mpm’s domain by definition. And mpm wraps asdf, mise and volta for what they install globally, so their rows say nothing about the per-project pinning that is the separate project-scope question.
Scope and competitor set. Feature/Operation rows cover only capabilities in mpm’s domain (cross-manager package operations, output, config, distribution). Do not add rows for a competitor’s out-of-domain features (a runtime version manager’s shims, task runner, env-var management, per-project version files). Columns are the wrapper peer group (topgrade, pacaptr, pacapt, sysget, whohas) plus brew (its Brewfile is a declarative multi-backend installer); mise/asdf were removed as out-of-scope version managers, kept only as managers mpm wraps in the generated table.
Auditing competitor cells. When (re)checking a column, research one competitor project at a time (parallel agents work well); each must verify every URL and quote and report “no evidence → blank” rather than infer a gap from absence.
Manager augmentations page (docs/augmentations.md)¶
Documents capabilities mpm backfills on top of native tools. Two classes: selective — only some managers need it (full upgrade --all, the synthesized orphan sweep of cleanup --orphans, exact/extended search), shown in the per-manager table — and universal — every managed tool gains it (--dry-run simulation, cross-scheme version parsing, purl identifiers, uniform sudo). The per-manager table renders live at Sphinx build time through the {python:render} block calling augmentations_table() from meta_package_manager/_docs.py, derived from the capability declarations (upgrade_all_is_synthesized(), cleanup_orphan_is_synthesized() and the search_capabilities flags in meta_package_manager/capabilities.py), so the rendered page never drifts from the code. The block carries the :mirror: flag like the benchmark table: a generated copy sits below the fence between <!-- mirror --> markers, refreshed by click-extra refresh-directives, never hand-edited. test_augmentations_table_renders guards the generator.
Concurrency page (docs/concurrency.md)¶
The user-facing half of what dispatch.py implements, and the only generic account of it: everything else on the subject is either a manager’s own Concurrency section or the contributor-facing docstrings in dispatch.py and § CLI output and logging below. All three of its renderings are {python:render} blocks with the :mirror: flag, derived rather than restated, so a lock family or a subcommand added in dispatch.py reaches the page with no edit here.
concurrency_table()glyphs every subcommand fromCOMMAND_FAN_OUT, whose ⇶⇶⇶/⇉⇶→/→→→ scale is local to this page.test_fan_out_covers_every_subcommandholds the catalog equal to the CLI’s registered commands, which is what forces a new subcommand to declare its mode; theFAN_OUT_NONEentries record the decision for the commands that drive no manager, and are left out of the render. The catalog is the one thing here that can drift silently, since no test reads a fan-out mode back off its call site: a subcommand switching primitive, or gainingreport_state=True, must be reflected in the same commit.lock_families_sankey()replaced the readme’s flatmpm→ manager fan-out, which had grown to a hundred-odd equal bands: a picture of the pool’s size rather than of its structure, and slow to lay out for it. The diagram is rooted at the serialized managers and shows the families alone. Adding the managers that share no backend would spend most of the canvas on one band, so they are a sentence instead. Each family’sbackendlabel must stay clear of every manager ID, mermaid identifying a sankey node by its label alone:test_lock_family_backends_are_distinctenforces it.lock_families_table()reuses each family’scontentionfragment verbatim under a Why? heading, the same string the per-manager pages complete a sentence with. Do not recase it into a standalone sentence: one wording, one place to fix it.
Snapshot page (docs/dump.md)¶
The canonical home of every Brewfile fact: which managers map to which entry keyword (a table generated from the pool’s own brewfile_entry_type declarations), what the header warns about, how taps and entry order are emitted, and the per-manager caveats (VSCodium extensions skipped, mas entries keyed by App Store ID, the uv/uvx split, the flatpak remote). Everywhere else keeps a one-line contextual mention pointing here: manager class docstrings, the bundled TOML description comments, the --brewfile option help, docs/configuration.md and docs/overrides.md. Never re-enumerate the covered managers or entry types in prose: that list drifted twice already, still naming uv after the mapping had moved to uvx. Read it off BUNDLE_ENTRY_TYPES or the generated table instead.
Per-manager pages (docs/managers/)¶
One documentation page per pool manager, plus the docs/managers.md hub. The invariants:
Stubs are generated — never hand-edit them. Each
docs/managers/<id>.mdis written byupdate_manager_stubs()indocs/docs_update.py(run by repomatic’supdate-docsjob), which owns the whole directory: it creates a stub per pool manager, rewrites drifted ones and deletes orphans. Adding or removing a manager needs no manual page work.test_manager_stubs_in_syncenforces byte-identity with the template.A generator edit does not invalidate the Sphinx cache. Incremental builds re-read a document only when its own source file changed, and editing
_docs.py(or a manager docstring, orchangelog.md) leaves every stub untouched: the pages then rebuild from cached doctrees carrying the previous output. Rebuild withsphinx-build --fresh-envafter touching a generator, or the localdocs/_buildshows work that is already done. CI is immune, building from scratch every time.A stub is one render block; the layout lives in
MANAGER_SECTIONS. Every stub is the same five lines callingmanager_page(), which prints the page title, then walks that tuple: an untitled entry renders as the lede, a titled one as its heading followed by itsmanager_*generator’s output. So a section added, renamed or dropped is a one-line edit inmeta_package_manager/_docs.pyinstead of a rewrite of a hundred stubs (nine such bulk rewrites happened before the split, one per layout change). The individual generators still emit heading-free MyST,manager_page()being the only source of a heading, whichtest_manager_page_sections_renderlocks. Headings once had to be committed into each stub,myst-parserhaving dropped a heading generated inside a nested parse; it supports them now (temp_root_node). What it still cannot do is keep content emitted above the first heading of that parse in place: it builds the sections then appends the loose lead nodes after them, so a lede printed before the first##lands at the foot of the page. Printing the#title first is what avoids that, by leaving nothing loose, and it is why the title cannot go back into the stub.test_manager_page_headings_survive_a_buildbuilds a page for real and asserts the title, the heading sequence and the infobox’s position, all three regressions being silent and none visible to a test of the generators alone. A fact that fits on one line belongs in the infobox (manager_card()), not in a section: that is where the invocation plumbing went (CLI names and lookup paths, forced arguments and environment, formerly a Howmpmdrives<id>section), leaving only what a box cannot hold — the version probe with its transcript and regexes.Generators read static declarations only — class attributes, the bundled TOML files (description comment, operation specs,
[samples]fixtures), theshell-sessionsamples documented in class/attribute docstrings (harvested viameta_package_manager.docstring_corpus, shared with the corpus round-trip test, in terminal-facingclass_display_blocksform for the reference traces) the hand-curated “Supported managers” table ofdocs/cooldown.md, whose per-manager rowmanager_cooldown()extracts (its id column partitions the pool, held bytest_cooldown_support_table_covers_the_pool; a missing row degrades the rendered page to a “not yet assessed” line),changelog.md, whose[scope]tagsscope_changelog()indexes into a per-scope release history, andlabels.py, whoseMANAGER_LABELSgives the card its tracker-search link (ecosystem siblings share one label, hence one search). Never touch host-probing properties (cli_path,version,available, installed packages): the pages must be identical on any build host, which is also why the card renders a path under the builder’s home as~-prefixed (SDKMAN resolves its search path from$SDKMAN_DIR).shell-sessionmeans fixture,consolemeans illustration. Everyinstalled/outdated/orphans/version_regexesblock written under a```{code-block} shell-session(orpwsh-session) fence is a complete sample: it must parse through the manager’s own parser (test_documented_output_still_parsesenforces it) and it renders verbatim as a reference trace, so it carries no(...)truncation marker (test_fixtures_carry_no_truncation_markerguards this; bare...in genuine CLI output like apt’sListing...is fine) and no shell pipe: it shows the exact argv mpm runs, not a| jqprettified view or anecho n |prompt feed (test_query_fixtures_run_verbatimguards this). A block that is not a literal fixture — a human-readable variant, an interactive prompt (sdkman’secho n | sdk upgrade), a narrative before/after transcript — uses the non-harvested```{code-block} consolefence instead: it still renders in the API docs but never reaches the corpus or the traces. There is no central exception registry; the fence language is the whole signal. Ashell-sessionfence is not itself proof of verification:test_documented_command_matches_constructionskips any documented command holding&&or a pipe, reading it as an illustration, so a mutation block written as a shell line (cd <dir> && make install) sits in the corpus unchecked while looking captured. Give such a block theconsolefence, and where the real argv cannot be rebuilt from a stand-in package id (Ports.installresolves its origin by query), say so in the docstring rather than leaving a reader to assume the block was verified.Manager class docstrings render outside autodoc.
manager_intro()inlines the class docstring straight into the MyST page after a{py:currentmodule}directive, so cross-references in those docstrings must be fully-qualified or module-sibling ({class}`PKG`,{meth}`Yay.cooldown_env`) — a bare class-member short ref resolves in the API docs but breaks on the manager page. A malformed fence (unclosed, or a 3-backtick fence nested inside another 3-backtick fence) garbles both pages: when a code block must nest inside an admonition, the outer admonition uses a colon fence (:::{note}). TOML managers render their file’s top description comment as the intro instead.Brand marks are vendored, never hotlinked. A manager’s
logoattribute (or TOML key) names an SVG underdocs/assets/managers/, whichdocs/logos_update.pyowns: it downloads them from a pinned Simple Icons release, normalizes each to a single unfilled line, and records title, brand color, source and license inlogos.yaml. Run it by hand, never from CI or a docs build: committed artwork keeps builds hermetic and immune to an upstream icon removal.manager_logo()inlines the SVG into the page instead of referencing it as an image, which is what lets CSS recolor it: the marks carry nofill, so they followcurrentColoron the dark theme and take their brand color on the light one, for every mark:MIN_LOGO_CONTRASTis measured and reported bydocs/logos_update.pybut never gates a render, since WCAG exempts logotypes and dropping pale marks back tocurrentColorrepainted recognizable brands a flat black. Remote logo URLs were assessed and rejected: linkcheck resolvesnodes.imageURIs, so 75 hotlinked marks would each cost a request against a budget already throttled to about one per minute on github.com. A manager whose upstream polices its mark simply declares nologoand keeps the page’s default package glyph: Microsoft’s legal team had every Microsoft mark removed from the set in its13.0.0(https://github.com/simple-icons/simple-icons/issues/11236, which also auto-closes re-requests as duplicates), sovscode,wingetandpwsh-gallerywill never have one, and Oracle’s went the same way (https://github.com/simple-icons/simple-icons/issues/11441), takingsun-toolswith it. Do not re-request those, and do not vendor the marks by hand. Twelve marks carry an attribution-bearing license, somanager_logo_credits()renders their credits from the manifest intodocs/license.md: crediting them is a license condition, not a courtesy.Upstream readings are split between the card and its own section, never stated twice. The weekly
sample-metricsreading committed asdocs/assets/metrics.csvgives the card two facts (stars, newest commit); everything else a forge knows about the project is the Upstream project section, wheremanager_upstream()renders live shields.io badges in the benchmark’s own three families (Activity, Popularity, Metadata). Live rather than sampled, because a date written in at build time starts ageing immediately while a badge is fetched when the page is read, and shields renders it as the distance to today, coloured by age.UPSTREAM_FORGESmaps each forge host to its badge family (a host missing there renders no section at all, whichtest_upstream_badges_cover_every_forgereports), andUPSTREAM_BADGESlists only the readings each family was verified to answer, since shields renders a red error image for an endpoint a forge cannot serve. That same red covers “no releases” and “repo not found” alike, so the release-only badges are gated on the sample’srelease_source: a project that tags without releasing (pip) shows its newest tag instead. All badge URLs are exempted from linkcheck inconf.py. This is not a licence to hotlink generally: brand marks stay vendored, per the rule above.Manager IDs link to the pages. The readme operation matrix (absolute
https://mpm.run/managers/<id>/URLs built bymanager_page_url(), exempted from linkcheck inconf.py), the benchmark first column (pool managers only), the augmentations table, the cooldown support table and the SBOM coverage matrix all link manager IDs to their page; home pages are listed on the pages themselves. The benchmarkmpm✅ keeps its source-line link. Prose follows the same rule: a manager named as a code span anywhere indocs/*.mdlinks to its own page, once per paragraph, so a name repeated in the next sentence stays plain while an enumeration is uniformly linked. Three places keep the bare span: a heading, where a link would rewrite the anchor other pages cross-reference; the benchmark’s own column headers and competitor cells, which name rival tools rather than wrapped managers; and thecooldown.mdcells the manager-page generators reuse verbatim, where a relative target resolves fromdocs/managers/and lands nowhere.
Installation and packaging pages (docs/install.md, docs/packaging.md)¶
docs/install.md is for end users installing mpm: every tab of its “Installation methods” tab-set opens with commands that work today, on the reader’s machine, whatever the channel’s upstream status. Everything aimed at distribution packagers lives in docs/packaging.md: the test-suite wiring, the dependency graph and click-extra compatibility matrix, and the per-channel catalog with its build walkthroughs and packaging rationale. A channel not yet released through its distro therefore carries the condensed build recipe from its packaging.md section, copied into the tab and trimmed to the commands, followed by a {admonition} naming the upstream pull request and inviting the reader to +1 it for native inclusion. Never demote such a tab to a status line plus a pointer: a one-liner the reader cannot run yet, sending them to another page for the one they can, is the shape this rule exists to forbid. The copy is the accepted cost of that: when a channel’s build steps change, update both pages. Packaging specs and their upstream submissions cite the page URL https://mpm.run/packaging/, never CLAUDE.md. The end-to-end procedure for adding a channel is the playbook at docs/add-packaging-channel.md; the three-file sync it enforces is the Distributor sync rule below.
Captured screenshots (docs/assets/*bar-*.png, docs/assets/gnome-shell-*.png)¶
Both frontends illustrate themselves from real sessions, driven by docs/bar_screenshots_update.py and docs/gnome_screenshots_update.py through the docs-screenshots.yaml workflow. Everything in an image is genuine: a real host renders a real menu, and only the package payload is held still, by a stand-in mpm serving docs/outdated-sample.json.
A committed capture must be byte-reproducible, and the proof is a pair of runs. Dispatch the workflow twice on the same commit: if pr-sync commits nothing the second time, the set has converged. Anything else rewrites all sixteen images on every push and the pull request stops carrying information. Reaching that took pinning six independent things a live desktop moves on its own, each recorded against the run that exposed it in the driver’s own comments: the menu bar clock, the drawn clock’s repaint, SwiftBar’s relative Updated footer, an unanswered automation alert, the order of Control Center’s menu bar modules, and the light-to-dark crossfade. Read those before adding anything to a frame.
Measure a difference before explaining it. When a capture moves, the rectangle and the count of pixels past a threshold say whether an element moved or a tint shifted, and the two have nothing in common. Guessing cost this project several 30-minute round trips; the window census (report_windows) and the whole-screen photograph behind the capture_screens dispatch input exist because measuring is cheaper. Leave that input off by default: each extra screencapture gives macOS another chance to raise the consent sheet the diagnostic is looking for.
Brand assets (docs/assets/)¶
The mark is flat and unoutlined: an isometric solid reconstructed from the three shades its planes catch, which is what the ANSI rendition in --version had always been and the SVG only became once the outline came off. So the palette is two purples and their midpoint: the ink #2d2364 on every left-facing face and on the lettering, the wash #d3d3f6 on every lit face, and #807bad on the right-facing ones. That third value is computed, never chosen — the per-channel average of the other two — so the palette stays two colors and a derivation. Everything else a source shows is one of the three at reduced opacity (the tagline is the ink at 80%, the social banner’s veins the ink at 12%), the single exception being that banner’s opaque background, the wash at 45% over white.
The open box shows its interior, and the interior mirrors the planes. Two walls fill the rim behind the floating cube, and each carries the tone of the plane it faces rather than the one it sits behind: the far-left wall is lit as a right-facing face, so the interior’s left half is the midpoint where the exterior’s is the ink. That mirroring is not decoration — it is what keeps each face of the floating cube landing on the opposite tone, so the cube stays legible at every size. The geometry is derived, not eyeballed: both walls are drawn full height and clipped to the rim quadrilateral, so they meet on the vertical through the back corner without any intersection maths, and the same rim test fills the interior cells of the ANSI grid.
docs/brand_update.py owns every raster under docs/assets/: the light and dark PNGs of the square logo, the banner and the social card, plus the .png/.ico/.icns bundle Nuitka ships. Run it by hand after editing an SVG, never from CI, and --check-palette reports any color that strayed. The mark also exists outside that directory, in the GNOME Shell extension’s icons/mpm-logo.svg, and moves with it.
A dark export moves the lettering and nothing else. The mark is one artwork on every surface, which is what going flat bought: a face carries its own value, where an ink outline on a dark background had nothing to stand against and dissolved. So DARK_THEME swaps .word/.sub onto the wash, repaints the social card’s background and veins, and leaves the mark alone. Its keys are whole CSS rules rather than bare colors, deliberately: .mpm-left names the same ink the wordmark does, and a color-keyed swap would repaint every shadowed plane. That also lets favicon.svg and the app icons ship a single rendering, a browser tab and a dock being surfaces the script cannot know the color of.
The terminal rendition in meta_package_manager/logo.py repeats the two colors as literals, runtime code having no access to the SVG sources, and shades its isometric faces with the closest xterm-256 entries to them. test_ansi_logo_tracks_the_brand_palette holds the constants and the artwork together.
Legal notices (docs/license.md)¶
The project’s single legal sink: license and copyright, the blanket trademark notice covering every manager name and mark the docs display, credits for third-party artwork (the vendored brand marks, Open Clipart mascots, Octicons, the XKCD strip), and a pointer to where dependency licenses live. Legalese goes here and nowhere else, so a credit is never stranded next to the artwork it covers, where nobody looks for it. The file keeps its license.md name (and license.html URL) to stay aligned with the upstream repomatic docs tree, even though the page now covers more than the license; its index.md entry stays last in the Development toctree. A new third-party asset means a new entry here, and an attribution-bearing license means the entry is mandatory.
Code style¶
Commit messages¶
No decorative prefixes. A
[bracketed]prefix is reserved for a mechanism that parses it back, and only[changelog] …qualifies, matched literally by repomatic’s auto-tagging job. Do not confuse it with the[scope]tags that open everychangelog.mdbullet: those name a manager or platform, live in the changelog file rather than in git, and are indexed byscope_changelog(). The two vocabularies are unrelated.
Named constants¶
In this codebase the grep is usually the point: SHARED_LOCK_FAMILIES, CANONICAL_ATTRS, MANAGER_SECTIONS and MANAGER_LABELS are each the single place a reader can enumerate a rule that is otherwise scattered across managers.
Workflow file naming¶
Related workflows share a prefix for visual grouping in the file listing: tests.yaml (unit/integration test suite) and tests-install.yaml (distributor installability tests). Apply the same pattern when adding new workflow files.
Workflow source URLs¶
Each job that tests a third-party distributor must have a comment above it with the precise URL(s) to verify the package’s status on that platform. Use the public-facing package page first (e.g., formulae.brew.sh), followed by the source definition (e.g., the GitHub-hosted formula .rb or manifest .json).
Distributor sync¶
docs/install.md (the “Installation methods” tab-set), docs/packaging.md (the per-channel catalog and build instructions) and .github/workflows/tests-install.yaml must stay in sync. All three carry cross-reference comments. When adding or removing a distributor, update them together: every channel gets a full install tab, whose commands are the released one-liner once the channel ships and the condensed build recipe until then.
Schedule-only workflows¶
Jobs that test released artifacts from external distributors (PyPI, Homebrew, Scoop, etc.) must not run on every push. They test the published version, not the code being pushed, so they belong on a schedule or manual dispatch only.
Command-line options¶
The long-form rule applies to every argv mpm constructs at runtime: the manager commands built by the manager classes and definitions, and the sudo invocations in meta_package_manager/sudo.py (sudo --non-interactive --validate, not sudo -n -v). Long forms make the --verbosity INFO command disclosure self-documenting.
A long form substitutes only when it takes the same arguments. Check the tool’s own --help before swapping one in: a short option sometimes has no long equivalent, and the similarly-named long option is a different option that takes a value. xbps-query -R enables repository mode and takes nothing, while --repository requires a URL, so --repository --search foo feeds --search in as that URL and reads foo as a package to describe. It exits 0 and prints plausible output, which is why only a reconstruction of the argv catches it.
CLI output and logging¶
mpm keeps two output channels distinct: the state of an operation (printed with echo) and log messages (logging, gated by --verbosity).
Verbosity tiers¶
The CLI defaults to WARNING (inherited from click-extra’s --verbosity default). Classify every logging call into one tier:
WARNING(default view): genuine problems only, such as failures with no other on-screen signal, the diagnosis tail of a failed command (its captured<stderr>, or<stdout>when that is empty; version probes anddoctorexempt, see_DIAGNOSIS_EXEMPT_OPERATIONS), safety notices (cooldown safeguard skipped, a file about to be overwritten, a silent CLI call that may be hiding asudopassword prompt), the end-of-run “N managers reported errors” summary, and timeouts. Pluscriticalfor fatal conditions. Keep it sparse.INFO(narration): the operational story, like the selection summary, install/dispatch priority, per-manager announcements, discovery (X has been installed with Y), capability skips (X does not implement Y), “ignoring option …” no-ops, and every CLI invocation run on the system (the reproducible$-prompt line with forced environment variables, so the user can replay by hand what mpm does). Version-detection probes are the exception and stay atDEBUG: they are discovery, fired for every candidate manager, and would drown the narration.DEBUG(technical): raw CLI output (streamed live, line by line, the manager ID glued into the level prefix asdebug:<manager_id>:), version-detection probes, result refiltering, manager-selection parsing, internal data dumps. Raw output stays atDEBUGeven for mutating operations, deliberately: streaming it atINFOwas assessed and dropped when issue 1938 closed satisfied without it, since line-pumped output cannot faithfully reproduce raw passthrough (each\rprogress redraw becomes its own prefixed line) and would swamp the narration tier. A failed run is the exception: the tail of its captured output promotes toWARNINGat the failure gate (issue 1968), because a failure’s stderr is its diagnosis while a success’s stderr is chatter, and a failed mutating operation cannot be re-run atDEBUGto regenerate it. If demand appears for watching live output with concurrency (DEBUGserializes to one worker viaserial_at_debug), the lever is ready:run_clitakes a per-calloutput_level, gated on_active_operationinCLIExecutor.run.
Heuristic for a new line: if it narrates a decision, a step, or a command run on the system it is INFO; a raw mechanism or a command’s output is DEBUG; something genuinely wrong and not already shown by the ✓/✗ trail is WARNING. “Your option had no effect here” is INFO, not WARNING.
A manager-scoped line passes extra={"label": manager.id} instead of naming the manager in the message: click-extra’s formatter renders the ID glued into the level prefix (warning:gem: Could not list installed packages.), matching the streamed CLI output lines and making logs grep-able by manager. Keep the ID in prose only where it is the object of the sentence (X has been installed with Y) or names a config artifact (No [gem] section found.).
An enum surfaced in any message must render as its bare member name: give it __str__/__format__ returning self.name. A functional Enum("Operations", (...)) otherwise leaks the Operations.outdated repr where the message wanted outdated.
Operation state: the ✓/✗ trail¶
Fan-out operations report state with a per-item ✓/✗ trail plus a persistent finisher, printed via echo to stderr, never logging. echo survives the WARNING default and is instead gated on an interactive terminal plus --progress, so pipes, CI and serialized runs stay clean.
Concurrency is decided by cross-manager ordering, not by whether a command mutates state. Three fan-out primitives, all bounded by --jobs:
Per manager, concurrent (
meta_package_manager.dispatch.collect_from_managers, one result per manager): commands whose work is independent and reported per manager. The read-only queries (installed/outdated/search), the maintenance commands (sync/cleanup/upgrade --all, which passreport_state=Truesince the trail is their only output), and the inventory exporters (dump/backup,sbom, which collect concurrently then assemble in manager order).Per package, concurrent across managers and serial within each (
meta_package_manager.dispatch.collect_per_package, one result per (package, manager)): the ordering-free state changersremove,upgrade <packages>,restore, and the manager-tied specs ofinstall. Managers run in parallel; one manager’s own packages run one at a time, since a manager cannot safely run two of its own invocations at once (seeSHARED_LOCK_FAMILIES).Sequential (
OperationTrailindispatch.py): onlyinstallwhen a package is left untied to a manager. Such a package needs a priority search (install with the first manager that has it, skip the rest), which is genuinely cross-manager-sequential.warn_jobs_ignorednotes atINFOwhen an explicit--jobsis therefore ignored.
The shared-lock families that make within-family concurrency unsafe (brew/cask over Homebrew’s update lock, apt/apt-mint/deb-get over dpkg, plus the RPM and pacman families) are catalogued in dispatch.py’s SHARED_LOCK_FAMILIES. The mutating fan-outs enforce them: merge_into_lock_lanes collapses each family into one dispatch lane, so its members run serially (one shared backend lock, never raced) while distinct families still run in parallel. The read-only queries take no backend lock and keep one lane per manager. A family lane also shares a command cache (CLIExecutor.run_cache), so members resolving to a byte-identical invocation (brew/cask both running brew update for sync) run the subprocess once. Adding a newly-conflicting set is a one-line edit: append a frozenset of ids to SHARED_LOCK_FAMILIES.
Trail conventions:
Two shapes: package-keyed (
✓ foo installed with brew, forinstall/remove/upgrade <packages>/restore) and manager-keyed (✓ brew,✓ Synced N/M managers, forsync/cleanup/upgrade --all).cleanupsuffixes each manager line with the categories dispatched to it (✓ brew (cache)), since the per-manager subsets differ.The finisher counts per (package, manager) attempt, matching the trail lines: a package acted on by two managers is
2/2, not1/1.A
✗line is TTY-only, so failures also emit acritical: Could not ...(shown everywhere) as the durable record and the non-zero-exit rationale. Keep both despite the overlap on a TTY.
Exit codes¶
Action commands (install, remove, upgrade <packages>, restore) collect per-package failures and exit non-zero with a critical: summary. -0/--zero-exit opts out of that gate (see exit_on_failures in cli.py): the summary still prints but the exit stays 0; usage and configuration errors keep exiting 2 regardless. Maintenance commands (sync, cleanup, upgrade --all) are best-effort: they mark a failed manager ✗ but stay exit-0. doctor is the third contract: read-only, it relays each manager’s native diagnosis verbatim to stdout (the one deliberate exception to the raw-output-at-DEBUG rule, as the report is the product and cannot be parsed), reads health from the diagnostic command’s exit code alone, and exits 1 when any manager reports problems (-0 opts out).
Testing guidelines¶
Test coverage is gated by the
[tool.coverage] report.fail_underratchet, which the parallel non-destructive run oftests.yamlis the one slice expected to clear; the partial slices opt out with an explicit--cov-fail-under=0. Coverage is off by default locally, since--covis passed by the workflow rather than sitting inaddopts: a focused localpytestnever trips the floor, and only a deliberate local--covdoes.A test class earns its place by sharing tests, never by grouping them.
tests/test_cli.pykeeps exactly two template classes,CLITableTestsandCLIQueryTests: each hands its subclasses a battery of inherited behavior tests (--columnsprojection, serialization across every format, query filtering) for the price of asubcmdfixture and a little per-command data (columns_registry,columns_test_pair). That much is the deliberate exception to the no-classes rule, kept by decision: dissolving it into a command×behavior parametrize was assessed and rejected, since it would pull each command’s specifics out of the command’s own file into a central cross-product harder to read and extend. Everything else is a function. A module inheriting no template writes plain module-level tests, the file being the grouping; shared assertion logic is a module-level helper (check_packages_payload,check_filtered_ids), and per-command parametrize data stays in the subcommand’s own file.The manager-selection strategy is an argument, not a base class.
check_manager_selection()takes the per-subcommandsignalscallable answering “did this manager show up?”, which eachtest_cli_*.pydefines as a module-levelevaluate_signals()and binds once withcheck_selection = partial(check_manager_selection, signals=evaluate_signals), keeping call sites free of plumbing. It used to arrive through anInspectCLIOutput/CLISubCommandTestspair that shared no test at all: two@staticmethodhelpers and an abstract strategy, delivered by inheritance and resolved through the MRO. The tell wasmanagers_table_signals(), already a module-level function so two hierarchies could share it, wrapped by subclasses in a@staticmethodthat forwarded to it. Selection itself is still exercised once, on a single subcommand, since the logic is shared, and a subcommand with no specific behavior needs notest_cli_*.pyfile at all.@pytest.mark.oncefor run-once tests. Two modules carry it today, both via a module-levelpytestmark:tests/test_metadata.py(which readspyproject.tomland the generated matrix) andtests/test_gnome_extension.py(which asserts on checked-in extension sources). The admission test is coverage, not just OS-independence: aoncemodule must import no package code beyond__version__, so moving it off the matrix cannot lower the slice that holds the coverage floor. A test that both coversmeta_package_managerand reads only static files stays on the matrix.Every destructive test declares its scheduling group. The destructive CI step runs
--dist=loadgroup, so a destructive test must resolve to anxdist_groupin one of three ways: parametrize it withmanager_id(the collection hook intests/conftest.pythen derives the group throughdestructive_group(), collapsingSHARED_LOCK_FAMILIESmembers onto one worker), mark it with an explicitxdist_groupwhen its manager is hardcoded, or mark itdestructive_all_managerswhen it drives every available manager at once, which routes it to the sequential cross-manager step. Collection fails on a destructive test with none of the three.tests/destructive_plan.pyholds the whole plan those tests read (the per-manager package, the per-host blockers, the groups), keepingconftest.pyto what the rest of the suite shares; itsDESTRUCTIVE_TEST_FAMILIESrecords the suite-level conflicts the lock catalog cannot know about: managers whose round-trips install the same package into the same target.The inherited battery runs on the fake pool, and both halves of that are enforced. A CLI test gets the host’s real managers by omitting
fake_pool, so the expensive, host-dependent path is the one an author lands on by accident, multiplied by however many subclasses inherit it. That is how a fifty-format rendering test became three hundred real-pool invocations, two hundred and fifty of them byte-identical, with nothing in the run reporting it: duplicate work looks like coverage. So a template test whose subject is the rendering (table formats, serializers, column projection) takesfake_pool, which also makes it deterministic on a runner carrying nothing, while one whose subject is the inventory keeps the real pool and is named inREAL_POOL_TEMPLATE_TESTS(exactly one per format family,test_json_output, not one per format).test_template_tests_read_their_subcommandandtest_template_tests_default_to_the_fake_poolhold both rules against every inherited test: a method that names its own subcommand belongs at module level, where it is collected once.Write conformance tests when fixing a class of bugs. The local instances are
test_content_order,test_manager_changelog_entriesandtest_documented_output_still_parses, which enumerate pool managers, generators, bundled TOML files and docstring corpus entries.A test that fakes an escalator binary must pin the escalator.
write_fake_executableanswers every argv identically, so a stand-insudoreturns its canned output tosudo --versiontoo.resolve_escalatorreads that as an impostor and falls through to the next escalator the host carries, which isrun0on a systemd runner and nothing on macOS, so the test passes locally and fails only in CI. It caches as well, so the wrong answer lands only when that test is the first in its worker to resolve one, which random ordering decides. Wrap such a test inonly_escalator("sudo"), as thebuild_clitests do; clearing the cache alone makes the failure deterministic rather than fixing it.TTY-gated output needs a pseudo-terminal to test. The
✓/✗trail, finishers and spinners only render on an interactive terminal, so click-extra’sCliRunner(non-TTY) never emits them — drive the CLI underpty.openpty()to exercise them. Most CLI tests instead assert on the stdout table, exit code, or an explicit--verbosity, none of which are TTY-gated.--dry-runsimulates read CLIs too. It dry-runs every manager invocation, including the installed-package lookup thatremove/upgradeuse to find their source managers — so a dry-run of those reports “not recognized” and cannot exercise their multi-manager path. Reach for purls (which carry the manager and bypass the lookup) or unit fixtures instead.--planruns reads but captures writes. The complement of--dry-run: plan mode executes the read-only queries (soinstall/remove/upgrade --allresolve their real source managers and targets), then records only the state-changing commands (_MUTATING_OPERATIONS) intoexecution.PLAN_RECORDERand prints them to stdout at context close, without running them. The capture keys on the operation in flight, not on the command, so a read issued from inside a mutating method is captured too and returns an empty string: a helper the command cannot be built without passesforce_exec=Trueor re-stamps itself withacting_as.force_execreads (version probes,yarn global dir) patchplanoff and run for real. Test it against real reads or purls, and assert on stdout: the plan is plainecho, not the TTY-gated trail.The suite is hermetic with respect to the host
mpmconfig. click-extra’s default--configsearch resolves to the host config folder (~/Library/Application Support/mpmon macOS,~/.config/mpmon Unix). Anyconfig.tomlthere would otherwise leak into every in-process CLI invocation: a localcpan = falsedrops the manager, socheck_manager_selectionassertions expecting the full default set fail locally while passing in CI. Theisolate_user_configautouse fixture intests/conftest.pyrepoints config discovery at an empty temp directory, so host config never reaches the suite. Tests that exercise config loading pass--config <path>explicitly, which overrides the default and is left unaffected.
Choosing test-matrix targets¶
repomatic show-metadata builds the full and PR matrices from [tool.repomatic.test-matrix.*], whose every deviation from the defaults is commented in pyproject.toml. tests/test_metadata.py turns a matrix that drifts from requires-python into a failing check. The selection conventions:
Spread the OS axis, not the Python one: they answer different questions. Every OS and architecture keeps a cell, since
mpmdrives a different set of managers on each and that spread is the product. Python version is interpreter compatibility, which is OS-independent, so the floor (3.10), the prerelease (3.15) and the free-threaded build (3.14t) each run on the single fastest runner (ubuntu-26.04-arm) while every other OS runs the ceiling alone. Linux hosts the floor because Linux is where a floor interpreter is actually used: distribution packagers build against whatever their channel ships, where macOS and Windows users install a Nuitka binary carrying a pinned one. The full cross-product was measured before being cut: across the 40 most recenttests.yamlruns, of the eleven with a failing cell, none failed on an OS whose other Python version passed, so the per-OS pairs cost 158 runner-minutes a push and caught nothing their survivor would have missed. Re-measure with the same question before widening it again, and note the one thing the pairs did give away for free: a single-cell failure no longer has a same-OS twin to tell a real bug from a host artifact, so diagnose one by re-running rather than by assuming.Pin the dependency floor, and any release a workaround targets. The floor of a supported range belongs in the matrix as an explicit value, along with any mid-range release a shim works around: that is the version that catches the shim regressing.
Select runners by measured speed and workload, not architecture. Where one fast runner suffices,
ubuntu-26.04-armis the fastest and cheapest tier (upstream measured ARM Linux 2-3x faster than the retired lean x86 image) and hosted macOS bills about ten times Linux, so macOS and Windows cells are reserved for the manager coverage only they add.remove.osdrops the slower twin of an OS pair. Every runner literal stays within repomatic’s curated axes (KNOWN_RUNNERS):lint-repoflags anything outside them, and actionlint validates the labels themselves against repomatic’s bundledactionlint.yaml, which declares the Ubuntu 26.04 preview pair that actionlint1.7.12predates. Declaring a[tool.actionlint]section here would replace that file rather than extend it, so a local label means restating every label repomatic ships and forfeiting the ones it adds later. The one deliberate exception ischeck-void-deps.yamlonubuntu-22.04, pinned by its apparmor comment.
Design principles¶
Keep logic in Python, not workflow YAML¶
Rather than asserting a project invariant with grep in a run: block, write the test: tests/test_docs.py and tests/test_metadata.py hold contracts that a shell one-liner would have expressed worse and silently stopped checking.
The corollary bounds how much a workflow may know: tests-install.yaml is long because each distribution channel genuinely needs its own install incantation, not because logic accumulated there.
Defensive workflow design¶
This project adds a second layer of flakiness on top of the usual races and partial failures: every job drives real package managers against live third-party feeds. The choco upgrade all and snap install code steps of tests.yaml are the models.
Distinguish absorbing a flake from hiding a failure: a forced exit 0 belongs on setup that is best-effort by nature, never on the assertion the job exists to make.
Single source of truth for defaults¶
The cases already carrying this weight are worth knowing, since each has a test holding it: the coverage floor in [tool.coverage] report.fail_under, the cooldown window in [tool.repomatic] minimum-release-age, the manager pool in meta_package_manager/pool.py, and the page layout in MANAGER_SECTIONS.
Ordering conventions¶
Manager class members: The canonical declaration order (identity, escalation policy, requirement, CLI plumbing, version probe, toggles, then methods in base-class order) is the
CANONICAL_ATTRStuple intests/test_managers.py, enforced bytest_content_order. Manager-specific constants (the_*_REGEXPparsers) conventionally sit between the attributes and the operations.
Issue and PR labelling¶
The content and file rules are generated into pyproject.toml from meta_package_manager/labels.py.
Content rules come only from each manager’s own
keywordsattribute: ecosystem, distro, language or brand names that unambiguously name the manager and never appear in mpm’s own output. Never the manager ID or a CLI name — mpm prints those for every installed manager (the✓ <id>trail, the<id>: <count>summary, themanagerstable), so a pasted trace would tag the issue with every manager on the user’s system. A manager whose only name is its ID declares none and gets no content rule.That attribute is the single source for both the labeller and the PyPI keywords, which is why the precision bar above governs what a manager may declare: a term naming an ecosystem rather than one manager belongs in
docs_update.py’sKEYWORDS_EXTRAS, which reaches PyPI alone. A label groups several managers, so its rule is the union of its members’ keywords (rpm-basedcollectsfedorafromdnfandmageiafromurpmi), and a subclass inherits its parent’s terms without claiming them:test_manager_keywords_have_one_ownerreads what a class declares, soapt-mintinheritingdebianfromaptis not a second owner.File rules map each manager’s own module and test paths to its label; keep them narrow enough that only that manager’s files match.
The generate_content_rules docstring covers the regex mechanics (anchoring, case-folding, why a label’s keywords are OR-joined into one pattern).
Common maintenance pitfalls¶
Module refactors strand fully-qualified docstring cross-refs. Moving an attribute between classes or modules (like the
7.3.0split that movedcli_pathandversionontoexecution.CLIExecutor) silently breaks every{attr}`x <old.path>`pointing at the old home, and the docs build only warns, never fails. After a move, grep the whole tree for the old dotted path. The same sweep rule applies when docstrings gain a new rendering surface (like the manager pages): one malformed fence, glued bullet list, or stale ref found means the whole corpus needs a sweep for that defect class, not a spot fix.Never reformat a hand-maintained table with an ad-hoc
mdformat. Several tables are parsed back by tests and generators against the exact row shape checked in, so a reformat that re-pads every cell reports as a content failure rather than a formatting complaint:test_unsupported_page_matches_benchmarkused to matchdocs/unsupported.mdrows with a padding-sensitive regex and answered “no manager rows found” when the table was realigned. That page is sections now, butdocs/cooldown.mdstill feeds the per-manager pages through_cooldown_table(). The repository pins nomdformatconfiguration of its own (theformat-markdownjob upstream owns it), so a local run resolves different defaults and different plugins than CI. Edit a table row by copying the padding of the row above it, and leave the rest of the file untouched: the diff stays three lines instead of a hundred and fifty, and nothing downstream breaks.[[tool.repomatic.labels.extra]]must stay the last array-of-tables of[tool.repomatic].docs_update.update_labels()drops the labels subtree and re-appends it at the end of the section, andtest_pyproject_updates_are_pyproject_fmt_fixpointfails on any array-of-tables sitting after it. A config wanting an array-of-tables shape goes in as an inline array value instead:[tool.repomatic.metrics] chartsis one.click-extra extension validators only see dict sub-trees. The opaque
[mpm]extension sections (MpmConfig’s mapping-typed fields:managers,cooldown) are forwarded to theirConfigValidatoronly when the value is a table; a string-shaped legacy value never reaches the validator, while the runtime path readingCONF_FULLsees every shape. Anything handling a deprecated or non-table spelling, a migration warning in particular, therefore belongs at runtime (thempmgroup body is where the cooldown one lives), not in the validator.
Comments and docstrings¶
Every URL in a docstring is a link. MyST’s
linkifyextension is off, so a barehttps://…renders as dead plain text on the manager pages and in the API docs alike. Write a titled markdown link ([`emerge(1)` man page](url)), keeping]and(on the same source line — a line break between them silently kills the link. A list of one reference is not a list: inline it asDocumentation: [title](url).and keep the bullets for two or more. Bare URLs inside a fenced block are captured CLI output and stay untouched. The bundled TOML definitions need none of this:_toml_definition_intro()autolinks their description comments.Documentation in
./docs/uses MyST markdown format where possible. Fallback to reStructuredText if necessary.