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 provides 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 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.
Deprecated managers: managers whose
deprecatedflag is setAre exempt from the rules above. A deprecated manager may be removed, in part or in full, in any release and without notice, once keeping it working becomes too burdensome. Each deprecation is documented via the manager’s
deprecation_url, and deprecated managers are kept out of the functional test matrices. See thedeprecatedattribute inmeta_package_manager/manager.pyfor the full policy.
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
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 --extra 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.
Type checking¶
$ uv run --group typing mypy meta_package_manager
Documentation¶
Build Sphinx documentation locally:
$ uv sync --extra docs
$ uv run -- sphinx-build -b html ./docs ./docs/html
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 reST admonitions like .. note:: and .. warning::). Trim the YAML comment to a one-line “what” plus a pointer.
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.
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 docs/docs_update.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. There is no checked-in copy to regenerate or keep in sync: the mpm-column ✅ links (class source-line anchors) are computed at build time, so they never drift from the manager source. test_benchmark_table_renders guards the generator against crashes and structural regressions.
Cell glyphs (benchmark only — readme.md’s operation matrix keeps plain ✓).
✅— supported. Thempm✅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. A competitor’s✅is a bare glyph.❌— 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 four alphabetically-sorted keys: managers (which competitor supports each manager), homepages (URLs for non-pool managers only), coarse_support ({manager: {competitor: url}}), and refused ({manager: {competitor: url}} for competitors that explicitly declined a manager mpm wraps). 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).
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, 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 docs/docs_update.py, derived from the capability declarations (upgrade_all_is_synthesized() and the search_capabilities flags in meta_package_manager/capabilities.py), so it never drifts from the code. 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.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 indocs_update.pyis the single source of truth for the page layout;test_manager_page_sections_renderlocks the heading-free invariant.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) and 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). Never touch host-probing properties (cli_path,version,available, installed packages): the pages must be identical on any build host.shell-sessionmeans fixture,consolemeans illustration. Everyinstalled/outdated/version_regexesblock written under a.. code-block:: shell-session(orpwsh-session) directive 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:: consoledirective instead: it still renders in the API docs but never reaches the corpus or the traces. There is no central exception registry; the directive is the whole signal.Manager class docstrings render outside autodoc.
manager_intro()embeds the class docstring in an{eval-rst}block opened withpy:currentmodule, so cross-references in those docstrings must be fully-qualified or module-sibling (:py:class:`PKG`,:py:meth:`Yay.cooldown_env`) — a bare class-member short ref resolves in the API docs but breaks on the manager page. The malformed-reST failure modes differ too: a broken directive or a list glued to its intro renders as garbled prose on both pages. TOML managers render their file’s top description comment as the intro instead.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) and the augmentations table all link manager IDs to their page; home pages are listed on the pages themselves. The benchmarkmpm✅keeps its source-line link.
Installation and packaging pages (docs/install.md, docs/packaging.md)¶
docs/install.md is for end users installing mpm: it holds installation methods only, never build-from-source or build-from-spec instructions. Everything for distribution packagers and anyone building from the in-repo packaging/*/ specs 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. A channel still pending upstream review keeps only a stub tab in install.md (status line, post-landing one-liner, link into its packaging.md section); its full build steps live in packaging.md alone. 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.
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, wrap them in backticks:`v1.2.3`,`1.2.3`. In reST docstrings, use double backticks:``v1.2.3``.Development versions follow PEP 440:
1.2.3.dev0with optional+{short_sha}local identifier.
Documenting code decisions¶
Document design decisions, trade-offs, and non-obvious implementation choices directly in the code using docstring admonitions (reST .. warning::, .. note::, .. caution::), 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.
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: released channels get a full install tab, channels pending upstream review get a stub tab pointing to their packaging.md section.
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, 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. 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).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.
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 tracked with
pytest-covand reported to Codecov.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 (manager-selection checks,--columnsprojection, serialization across every format, query filtering) for the price of asubcmdfixture and anevaluate_signals()strategy. Dissolving it 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. Define a customoncemarker (in[tool.pytest].markers) to tag tests that only need to run once — not across the full CI matrix. Typical candidates: CLI entry point invocability, plugin registration, package metadata checks. The main test matrix filters them out withpytest -m "not once", while a dedicatedonce-testsjob runs them on a single runner.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.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.
Design principles¶
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.
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:py: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 directive (..tip::), 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.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.
Comments and docstrings¶
All comments in Python files must end with a period.
Docstrings use reStructuredText format (vanilla style, not Google/NumPy).
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.
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.