Contribution guide¶
Good candidates for new package manager:
Benchmark of other similar tools
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¶
This file provides guidance to Claude Code when working with code in this repository.
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.
Upstream conventions¶
This repository uses reusable workflows from kdeldycke/repomatic and follows the conventions established there. For code style, documentation, testing, and design principles, refer to the upstream claude.md as the canonical reference.
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.
Source of truth hierarchy¶
CLAUDE.md defines the rules. The codebase and GitHub (issues, PRs, CI logs) are what you measure against those rules. When they disagree, fix the code to match the rules. If the rules are wrong, fix CLAUDE.md.
Keeping CLAUDE.md lean¶
CLAUDE.md must contain only conventions, policies, rationale, and non-obvious rules that Claude cannot discover by reading the codebase. Actively remove:
Structural inventories — project trees, module tables, workflow lists. Claude can discover these via
Glob/Read.Code examples that duplicate source files — YAML snippets copied from workflows, Python patterns visible in every module. Reference the source file instead.
General programming knowledge — standard Python idioms, well-known library usage, tool descriptions derivable from imports.
Implementation details readable from code — what a function does, what a workflow’s concurrency block looks like. Only the rationale for non-obvious choices belongs here.
Philosophy¶
First create something that works (to provide business value).
Then something that’s beautiful (to lower maintenance costs).
Finally works on performance (to avoid wasting time on premature optimizations).
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¶
Every command that resolves a package from a live registry carries a cooldown, except where this section names otherwise. A cooldown refuses any version published more recently than a fixed window, so a compromised release has to survive that window before it can enter a build. Most malicious releases (stolen publishing credentials, dependency confusion, account takeover) are caught and pulled within days of publication, which is what makes a window of days worth the delay it costs.
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 rule has no scratch exemption. It binds reusable workflows, one-off CI steps, test scripts, local reproduction commands and throwaway experiments equally: an uncooled uvx in a five-minute debugging step resolves the same tree from the same registry onto the same runner as a production job.
A cooldown is not a pin, and neither is a checksum¶
The three guarantees are independent, and most of what CI installs has only one or two of them. Know which one you are relying on before calling something verified.
Guarantee |
What it proves |
Where this repo has it |
|---|---|---|
Cooldown |
The version has been public long enough for a compromise to surface |
Every |
Pin |
Everyone resolves the same version |
Action SHAs, the inline |
Checksum |
The bytes are the bytes that version shipped |
|
The gap worth naming: a uvx-resolved tree is gated by publication age but never checked against a known digest, because a uvx environment has no lockfile. uv.lock is the only place a Python dependency is hash-pinned, so anything resolved outside it trades hash verification for the cooldown alone.
Where the window comes from¶
[tool.repomatic] minimum-release-age is the single source of truth, left at its 1 week default here. Never hard-code a duration next to an install command: read it from config, or from the npm_min_release_age_days output repomatic metadata derives from it.
Two places carry the duration as a literal instead, and both must be kept equal to that source by hand:
Every workflow that installs anything, because YAML cannot read Python: each sets
NPM_CONFIG_MIN_RELEASE_AGEandUV_EXCLUDE_NEWERin a workflow-levelenv:block. Job-level would not cover the bootstrap, sincemetadataresolves packages before any other job’s output exists and a workflow-levelenv:cannot referenceneeds. The literal covers every job, including that bootstrap and any step added later by someone who never read this section.[tool.uv] exclude-newer, because uv reads its own config and knows nothing of[tool.repomatic]. The two must not merely be close: a lock window wider than the install window resolves versions those installs then refuse, leaving a package pinned inuv.lockthat CI cannot install.
That makes the cooldown the one place an environment variable beats an explicit flag, inverting § uv flags in CI workflows: a flag only protects the command someone remembered to write it on, and the commands that most need protecting are the ones nobody thought about.
Per-ecosystem knobs¶
Ecosystem |
Cooldown |
Per-package exemption |
|---|---|---|
uv: |
|
|
npm, |
|
|
uv accepts a friendly duration (1 week), an ISO 8601 span (P7D), or an absolute date; npm counts whole days and needs 11.10.0 or newer. Both knobs gate the whole resolved tree, transitive dependencies included, which is the point: the compromised package is rarely the one named on the command line.
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.
Distro archives are out of scope, not an exception¶
apk, pacman, xbps and their peers are not live registries, and this rule was never about them. A stable archive is frozen at release and moves only through the distro’s own staging, which is a cooldown implemented one layer down. Nobody self-publishes into it, which is the property the window exists to compensate for everywhere else. A distro version string is also the maintainer’s package build, not an upstream publish date, so a publish-date filter would have nothing to filter on. That is why tests-yay-cooldown.yaml, check-void-deps.yaml and the *-source jobs of tests-install.yaml carry no window: they drive pacman, xbps-src, abuild and nix-build inside their own distro containers.
The exception is a repository added by hand. A PPA or a vendor’s .repo file is a live, single-publisher registry wearing apt’s clothes, with none of the distro staging behind it. Pin the version there, or fetch a checksummed artifact instead.
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 dependency held at a known-good version.
[tool.uv] exclude-newer-packageholdsextra-platformsat a fixed date, which keepsuv lock --upgradeon that version instead of tracking newer releases.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 git@github.com: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. Run them sequentially:
$ uv run pytest --numprocesses=0 --skip-non-destructive --run-destructive
Sequential order is recommended as most package managers don’t support concurrency.
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://kdeldycke.github.io/meta-package-manager/packaging.html. 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¶
Scope of CLAUDE.md vs readme.md¶
CLAUDE.md: Contributor and Claude-focused directives — code style, testing guidelines, design principles, and internal development guidance.readme.md: User-facing documentation — installation, usage, and public API.
When adding new content, consider whether it benefits end users (readme.md) or contributors/Claude working on the codebase (CLAUDE.md).
Knowledge placement¶
Each piece of knowledge has one canonical home, chosen by audience. Other locations get a brief pointer (“See module.py for rationale.”).
Audience |
Home |
Content |
|---|---|---|
End users |
|
Installation, configuration, usage. |
Developers |
Python docstrings |
Design decisions, trade-offs, “why” explanations. |
Workflow maintainers |
YAML comments |
Brief “what” + pointer to Python code for “why.” |
Bug reporters |
|
Reproduction steps, version commands. |
Contributors / Claude |
|
Conventions, policies, non-obvious rules. |
YAML to Python distillation: When workflow YAML files contain lengthy “why” explanations, migrate the rationale to Python module, class, or constant docstrings (using MyST admonition fences like ```{note} and ```{warning}). Trim the YAML comment to a one-line “what” plus a pointer.
Example data¶
Invented example data (docs, docstrings, comments, test fixtures) must be domain-neutral: cities, weather, fruits, animals, recipes. 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¶
Always update documentation when making changes:
changelog.md: Add a bullet point describing what changed (new features, bug fixes, behavior changes), not why. Keep entries concise and actionable. Justifications and rationale belong in documentation or code comments, not in the changelog.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: manager_changelog() indexes the tags to build the release-history section of every manager page. Two 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.
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.yaml; 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 theUnmaintainedcolumn of the manager index, the same fact inreadme.md’s operation matrix, and the benchmark’smpmcolumn where it replaces the ✅ that manager would otherwise get.☠️ — never wrapped. The upstream is dead, so
mpmdeclined to write the manager at all. Marks theStatuscolumn ofdocs/unsupported.mdand the benchmark’smpmcolumn (see theunsupportedkey below), alongside ❌ for a live tool declined on its own merits.
The benchmark’s mpm column is therefore a five-state scale, and the two families never mix: ✅/⚠️ link to the implementing class, ☠️/❌ to the decision not to write one, and a blank cell means the tool was never assessed.
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.yaml 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 (a flat list of managers mpm itself declined). test_benchmark_yaml_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).
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 link target lives once in _docs.py’s UNSUPPORTED_DOCS_URL, never repeated per manager.
docs/unsupported.md is the user-facing record, not the reasoning. It carries the table of excluded tools and the reason per row, and nothing else. 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.
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.Headings live only in the stubs. Every section body is a
{python:render}block calling amanager_*generator, and those generators must emit heading-free MyST: the directive nested-parses its output into the surrounding document, where MyST headings rely on fragile section reparenting. TheMANAGER_SECTIONStuple inmeta_package_manager/_docs.pyis the single source of truth for the page layout;test_manager_page_sections_renderlocks the heading-free invariant. 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 (keep itsmpmid column in sync with the pool; a missing row degrades to a “not yet assessed” line),changelog.md, whose[scope]tagsmanager_changelog()indexes into a per-manager 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.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.Manager IDs link to the pages. The readme operation matrix (absolute
https://kdeldycke.github.io/meta-package-manager/managers/<id>.htmlURLs, 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://kdeldycke.github.io/meta-package-manager/packaging.html, 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.
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.
File naming conventions¶
Extensions: prefer long form¶
Use the longest, most explicit file extension available. For YAML, that means .yaml (not .yml). Apply the same principle to all extensions (e.g., .html not .htm, .jpeg not .jpg).
Filenames: lowercase¶
Use lowercase filenames everywhere. Avoid shouting-case names like FUNDING.YML or README.MD.
GitHub exceptions¶
GitHub silently ignores certain files unless they use the exact name it expects. These are the known hard constraints where you cannot use .yaml or lowercase:
File |
Required name |
Why |
|---|---|---|
Issue form templates |
|
|
Issue template config |
|
|
Funding config |
|
Only |
Release notes config |
|
Only |
Issue template directory |
|
Must be uppercase; GitHub ignores lowercase |
Code owners |
|
Must be uppercase; no extension |
Workflows (.github/workflows/*.yaml) and action metadata (action.yaml) officially support both .yml and .yaml — use .yaml.
Code style¶
Terminology and spelling¶
Use correct capitalization for proper nouns and trademarked names:
PyPI (not
PyPi) — the Python Package Index. The “I” is capitalized because it stands for “Index”. See PyPI trademark guidelines.GitHub (not
Github)GitHub Actions (not
Github ActionsorGitHub actions)JavaScript (not
Javascript)TypeScript (not
Typescript)macOS (not
MacOSormacos)iOS (not
IOSorios)
Version formatting¶
The version string is always bare (e.g., 1.2.3). The v prefix is a tag namespace — it only appears when the reference is to a git tag or something derived from a tag (action ref, comparison URL, commit message). This aligns with PEP 440, PyPI, and semver conventions.
Context |
Format |
Example |
Rationale |
|---|---|---|---|
Python |
|
|
PEP 440 bare version. |
Git tags |
|
|
Tag namespace convention. |
GitHub comparison URLs |
|
|
References tags. |
GitHub action/workflow refs |
|
|
References tags. |
Commit messages |
|
|
References the tag being created. |
CLI |
|
|
Package version, not a tag. |
Changelog headings |
|
|
Package version, code-formatted. |
PyPI URLs |
|
|
PyPI uses bare versions. |
Rules:
No
vprefix on package versions. Anywhere the version identifies the package (PyPI, changelog heading, CLI output,pyproject.toml), use the bare version:1.2.3.vprefix on tag references. Anywhere the version identifies a git tag (comparison URLs, action refs, commit messages, PR titles), usev1.2.3.Always backtick-escape versions in prose. Both
v1.2.3(tag) and1.2.3(package) are identifiers, not natural language. In markdown and in MyST docstrings alike, wrap them in single backticks:`v1.2.3`,`1.2.3`.Development versions follow PEP 440:
1.2.3.dev0with optional+{short_sha}local identifier.
Commit messages¶
Default to a subject line and nothing else, when there is no context to link. A commit message is a log entry, not a design document.
Subject. One line under 72 characters, imperative mood, capitalized, no trailing period, every identifier backticked. Name what changed, not the category it falls in:
Sync `uv.lock`,Fix `yay` cooldown overlay on Arch. Avoid the bare one-word subject (Typo,Lint,Fix): it costs the next reader agit showto learn anything. Say what the typo was in, what the lint fixed.No decorative prefixes. This is not Conventional Commits: no
feat:,chore:,fix:. 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 bymanager_changelog(). The two vocabularies are unrelated. Never write a GitHub skip token ([skip ci]and its aliases) in any message, including a body: they match anywhere and leave a required check “Pending” rather than failing.Body: link the context. Omit it when the subject says everything. Write one short paragraph when the why is not evident from the diff, and especially when the decision was made somewhere public: the upstream manager’s issue tracker, the distro packaging PR, the spec page that forced the behavior. Forges render commit messages as HTML, so a link is the cheapest route from
git logto the full story. Format every cross-repository reference as[owner/repo#N](https://github.com/owner/repo/issues/N).
Never narrate the work in sequence or enumerate the files touched: git log --stat lists the files and the diff shows the order. Rationale needing more room than a paragraph belongs somewhere durable instead, per § Knowledge placement.
Documenting code decisions¶
Document design decisions, trade-offs, and non-obvious implementation choices directly in the code using docstring admonitions (MyST fences like ```{warning}, ```{note}, ```{caution}; a colon fence :::{note} when a code block must nest inside), inline comments, and module-level docstrings for constants that need context.
__init__.py files¶
Keep __init__.py files minimal. They are easy to overlook when scanning a codebase, so avoid placing logic, constants, or re-exports in them. Acceptable content: license headers, package docstrings, from __future__ import annotations, and __version__ (standard Python convention for the root package). Anything else belongs in a named module.
TYPE_CHECKING block¶
Place a module-level TYPE_CHECKING block after all imports (including version-dependent conditional imports). Use TYPE_CHECKING = False (not from typing import TYPE_CHECKING) to avoid importing typing at runtime. See existing modules for the canonical pattern.
Only add TYPE_CHECKING = False when there is a corresponding if TYPE_CHECKING: block. If all type-checking imports are removed, remove the TYPE_CHECKING = False assignment too — a bare assignment with no consumer is dead code.
Modern typing practices¶
Use modern equivalents from collections.abc and built-in types instead of typing imports. Use X | Y instead of Union and X | None instead of Optional. New modules should include from __future__ import annotations (PEP 563).
Minimal inline type annotations¶
Omit type annotations on local variables, loop variables, and assignments when mypy can infer the type from the right-hand side. Annotations add visual noise without helping the type checker.
When to annotate: Add an explicit annotation only when mypy cannot infer the correct type and reports an error — e.g., empty collections that need a specific element type (items: list[Package] = []), None initializations where the intended type isn’t obvious from later usage, or narrowing a union that mypy doesn’t resolve on its own.
Function signatures are unaffected. Always annotate function parameters and return types — those are part of the public API and cannot be inferred.
Named constants¶
Do not inline a named constant during a refactor. It exists for readability and grep-ability, and 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. When moving code between modules, carry the constant with it rather than replacing it with a literal at the call site.
Python 3.10 compatibility¶
This project supports Python 3.10+. Be aware of syntax features not available in Python 3.10:
Multi-line f-string expressions (Python 3.12+): Cannot break an f-string after
{onto the next line.Exception groups and
except*(Python 3.11+).Selftype hint (Python 3.11+): Usefrom typing_extensions import Selfinstead.
Imports¶
Place imports at the top of the file, unless avoiding circular imports. Never use local imports inside functions — move them to the module level. Local imports hide dependencies, bypass ruff’s import sorting, and make it harder to see what a module depends on.
Version-dependent imports (e.g.,
tomllibfallback for Python 3.10) should be placed after all normal imports but before theTYPE_CHECKINGblock. This allows ruff to freely sort and organize the normal imports above without interference.
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.
Non-interactive CI¶
When a third-party tool prompts interactively (path selection, asset selection), pre-create its config files and resolve inputs via gh or other CLI tools rather than piping stdin. This is more robust across platforms, especially Windows where stdin redirection often fails with “Incorrect function.”
YAML workflows¶
For single-line commands that fit on one line, use plain inline run: without any block scalar indicator:
# Preferred for short commands: plain inline.
- name: Install project
run: uv --no-progress sync --frozen --all-extras --group test
When a command is too long for a single line, use the folded block scalar (>) to split it across multiple lines:
# Preferred for long commands: folded block scalar joins lines with spaces.
- name: Unittests
run: >
uv --no-progress run --frozen -- pytest
--cov-report=xml
--junitxml=junit.xml
Use literal block scalar (|) only when the command requires preserved newlines (e.g., multi-statement scripts, heredocs):
# Use | for multi-statement scripts.
- name: Install Python
run: |
set -e
uv --no-progress venv --python "${{ matrix.python-version }}"
YAML lines may run up to 120 characters (yamllint sets line-length: max: 120): don’t carry Python’s 88-character limit over to workflow comments or reflexively wrap them at 80.
Command-line options¶
Always prefer long-form options over short-form for readability when invoking commands in workflow files and scripts:
Use
--outputinstead of-o.Use
--verboseinstead of-v.Use
--recursiveinstead of-r.
The same 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.
uv flags in CI workflows¶
When invoking uv and uvx commands in GitHub Actions workflows:
--no-progresson all CI commands (uv-level flag, placed before the subcommand). Progress bars render poorly in CI logs.--frozenonuv runcommands (run-level flag, placed afterrun). The lockfile should be immutable in CI.Flag placement:
uv --no-progress run --frozen -- command(notuv run --no-progress).Exceptions: Omit
--frozenforuvxwith pinned versions,uv tool install, CLI invocability tests, and local development examples.Prefer explicit flags over environment variables (
UV_NO_PROGRESS,UV_FROZEN). Flags are self-documenting, visible in logs, avoid conflicts (e.g.,UV_FROZENvs--locked), and align with the long-form option principle.
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¶
Use
@pytest.mark.parametrizewhen testing the same logic for multiple inputs. Prefer parametrize over copy-pasted test functions that differ only in their data — it deduplicates test logic, improves readability, and makes it trivial to add new cases.Keep test logic simple with straightforward asserts.
Tests should be sorted logically and alphabetically where applicable.
Test coverage is measured with
pytest-covand gated by the[tool.coverage] report.fail_underratchet, which the parallel non-destructive run oftests.yamlis the one slice expected to clear. Declare the floor there and nowhere else: a--cov-fail-underflag outranks the config, so the partial slices opt out with an explicit--cov-fail-under=0rather than the full run passing a value. 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.Do not use classes for grouping tests. Write test functions as top-level module functions. Only use test classes when they provide shared fixtures, setup/teardown methods, or class-level state.
The CLI template-class hierarchy is a deliberate exception, kept by decision.
tests/test_cli.py’sCLISubCommandTests/CLITableTests/CLIQueryTeststemplates give eachtest_cli_*.pysubclass a battery of inherited behavior tests (--columnsprojection, serialization across every format, query filtering) for the price of asubcmdfixture. Only the subclasses that assert manager selection (install/remove,upgrade,backup,restore,sbom,managers) additionally implement anevaluate_signals()strategy forcheck_manager_selection(): the per-subcommand selection battery itself was retired (selection is exercised once, on a single subcommand, since the logic is shared), so a query-only subclass carries noevaluate_signals()and a subcommand with no subclass-specific behavior needs notest_cli_*.pyfile at all. Dissolving the hierarchy into a command×behavior parametrize was assessed and rejected: it would trade colocated per-command specifics for a cross-product harder to read and extend. Shared assertion logic goes on the template classes (or module helpers likecheck_packages_payload), while per-command parametrize data stays in the subcommand’s own file.@pytest.mark.oncefor run-once tests. Theoncemarker (declared in[tool.pytest].markers) tags tests that only need to run once, not across the full CI matrix. The matrixtestsjob filters them out withpytest -m "not once", and theonce-testsjob oftests.yamlruns them on a single runner. 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.Write conformance tests when fixing a class of bugs. For a bug that is a category rather than a one-off, add a generic test locking in the invariant: enumerate every member of the set (pool managers, generators, bundled TOML files, docstring corpus entries) and assert the property uniformly, failing with the violator’s name. This is why
test_content_order,test_manager_changelog_entriesandtest_documented_output_still_parsesexist. Applies when the bug stems from a shared convention checkable from the codebase alone, with no fixtures or mocks.CI-only pytest flags belong in workflow steps, not
[tool.pytest].addopts. Flags that emit CI-only artifacts (--cov-report=xml,--junitxml=junit.xml) pollute local runs when placed inaddopts: keepaddoptsfor flags that apply everywhere and pass CI-specific ones in the workflowrun:step. Coverage settings (run.branch,run.source,report.precision) belong in[tool.coverage], not in--cov-*flags.Pass
encoding="UTF-8"tosubprocess.run(..., text=True)when output may contain non-ASCII bytes (emoji in a workflowname:, accented author names, translated strings).text=Truealone decodes with the platform default (cp1252on Windows), so such output raisesUnicodeDecodeErroronly in Windows CI while passing on macOS and Linux. Test helpers shelling out to package managers orgitare the usual offenders.Pass an explicit encoding to every text-mode
open(),read_text()andwrite_text()in tests, same as production. The same Windowscp1252default applies to file I/O, and the failure stays hidden until the content grows its first non-ASCII character, which manager output and docstring samples do constantly. When a change touches file I/O, run the suite once withPYTHONWARNDEFAULTENCODING=1(PEP 597) to surface every bare call at runtime, on any platform: a linter misses the unannotatedPathlocals.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.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 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:
Cover the shipped config broadly, probe unreleased axes narrowly, and let the OS spread pay for itself. Released dependencies on stable Python get the full cross-platform spread, since
mpmdrives a different set of managers on each OS and that spread is the product. Prerelease Python (3.15) keeps repomatic’sunstableflag and runs on one runner; a released free-threaded build (3.14t) runs stable, also on one runner, because interpreter-level compatibility is OS-independent.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 (see the[tool.actionlint]stopgap for the Ubuntu 26.04 preview pair). The one deliberate exception ischeck-void-deps.yamlonubuntu-22.04, pinned by its apparmor comment.
Design principles¶
Keep logic in Python, not workflow YAML¶
Push anything beyond trivial wiring out of workflow YAML and into the package or its tests. Rather than duplicating an if: condition across steps, compute it once and reference the result. 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. A tested generator that fails loudly beats a static artifact that can drift.
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¶
GitHub Actions workflows face race conditions, eventual consistency and partial failures, and this project adds a second layer of flakiness on top: every job drives real package managers against live third-party feeds. Prefer belt-and-suspenders, several independent correctness mechanisms over one guarantee. When a step depends on external state (a CDN, an upstream release, a snap store), add a retry or a graceful default and say in a comment what transient failure it absorbs. 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¶
Every configurable default lives in exactly one place, and all other code derives it rather than repeating the literal. When adding one, grep for the value and point every other occurrence at the source. 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.
Linting and formatting¶
Linting and formatting are automated via GitHub workflows. Developers don’t need to run these manually during development, but are still expected to do best effort. Push your changes and the workflows will catch any issues and perform the nitpicking.
Ordering conventions¶
Keep definitions sorted for readability and to minimize merge conflicts:
Workflow jobs: Ordered by execution dependency (upstream jobs first), then alphabetically within the same dependency level.
Python module-level constants and variables: Alphabetically, unless there is a logical grouping or dependency order. Hard-coded domain constants should be placed at the top of the file, immediately after imports. These constants encode domain assertions and business rules — surfacing them early gives readers an immediate sense of the assumptions the module operates under.
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.YAML configuration keys: Alphabetically within each mapping level.
Documentation lists and tables: Alphabetically, unless a logical order (e.g., chronological in changelog) takes precedence.
Prefer uv over pip in documentation¶
Documentation and install pages must use uv as the default package installer. When showing how to install the package, use uv tool install (for CLI tools) or uv pip install (for libraries/extras). Alternative installers (pip, pipx, etc.) may appear as secondary options in tab sets or dedicated sections, but uv must be the primary/default command shown.
Idempotency by default¶
Workflows and CLI commands must be safe to re-run. Running the same command or workflow twice with the same inputs should produce the same result without errors or unwanted side effects.
In practice: use --skip-existing, check for existing state before writing, prefer upsert semantics, make file-modifying operations convergent.
Issue and PR labelling¶
The content and file rules generated into pyproject.toml from meta_package_manager/labels.py only pre-label a freshly filed issue or PR: they save the maintainer a first pass, never replace the manual review and classification, and nothing downstream treats them as authoritative. Tune for precision, not recall — encode a rule only when the signal is unambiguous, and none when it is not (that manager is then labelled by hand).
Content rules come only from
MANAGER_CONTENT_KEYWORDS: 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 gets no content rule.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). This is the local instance of the labeller principle in repomatic’s claude.md.
Common maintenance pitfalls¶
Documentation drift is the most frequent issue. CLI output, version references, and workflow job descriptions in
readme.mdgo stale after every release or refactor. Always verify docs against actual output after changes.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.CI debugging starts from the URL. When a workflow fails, fetch the run logs first (
gh run view --log-failed). Do not guess at the cause; when the user points to a specific failure, diagnose that exact one.Trace to root cause before coding a fix. Audit a bug’s scope before writing the patch. If the same pattern appears in multiple places, fix it at the shared layer; if only one call site is affected, check whether the data is on the wrong code path before handling it where it lands.
Never reformat a hand-maintained table with an ad-hoc
mdformat. Several tables are parsed back by tests and generators, and the parsers are written against the exact row shape checked in.test_unsupported_page_matches_benchmarkmatchesdocs/unsupported.mdrows with a regex expecting single-space cell padding, so re-padding that table into aligned columns makes every row invisible and the test reports “no manager rows found” rather than a formatting complaint. 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.Angle-bracket placeholders in bash code blocks.
mdformat-shfmtrunsshfmton fenced```bashblocks, andshfmtparses<foo>/>fooas redirection and reorders the command. Use curly braces ({foo}) for placeholders in bash examples.Type-checking divergence. Code that passes
mypylocally may fail in CI where--python-version 3.10is used. Always consider the minimum supported Python version.Simplify before adding. When asked to improve something, first ask whether existing code or tools already cover the case. Remove dead code and unused abstractions before introducing new ones.
Route through existing infrastructure, don’t bypass it. Before writing a new helper or merge function, check whether the codebase already handles the operation. A bug from data on the wrong code path is better fixed by routing it correctly than by duplicating logic at the wrong site.
Comments and docstrings¶
All comments in Python files must end with a period.
Docstrings are written in MyST markdown: single-backtick code spans,
{role}cross-references in the unprefixed form ({class},{meth},{func},{attr},{data},{mod},{exc}), markdown links, and backtick-fenced directives. click-extra’smyst_docstringsSphinx extension converts them back to reST at build time, so autodoc is unaffected. Field lists (:param x:,:return:) keep their reST syntax, which passes through the conversion. A brace-bearing literal keeps reST double backticks (like) so the converter cannot misread it as a role. Theclick-extra convert-to-mystcommand migrates legacy reST docstrings idempotently.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.Keep lines within 88 characters in Python files, including docstrings and comments (ruff default). Markdown files have no line-length limit — do not hard-wrap prose in markdown. Each sentence or logical clause should flow as a single long line; let the renderer handle wrapping.
Titles in markdown use sentence case.
Heading anchors: use the natural auto-generated anchor for cross-references; add explicit MyST anchors (
(my-anchor)=) only when the natural one is unavailable (duplicate headings, non-heading targets).Dataclass field docs: In dataclasses, document fields with attribute docstrings (a string literal immediately after the field declaration), not
:param:entries in the class docstring. Attribute docstrings are co-located with the field they describe, recognized by Sphinx, and stay in sync when fields are added or reordered. The class docstring should contain only a summary of the class purpose.