What makes a repository a modern-python repository. It is written for the maintainer, contributors, and the coding agents that work across the org's repositories. It is not a guide to writing Python; it is the shape every repo shares so that tooling, CI, and agents can rely on it.
The standard applies to every repo that publishes a package to PyPI under the org.
Each requirement has a stable ID (CI6) and an anchor of the same name
(https://modern-python.org/standard/#CI6). Cite the ID, not a heading.
The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.
- MUST and MUST NOT form the core. A repo meets every core requirement, or carries an exemption for it.
- SHOULD and SHOULD NOT are recommendations. A pull request that departs from one says why.
- MAY is a permission.
A Why line under a requirement is its rationale, not part of the requirement.
A repo MUST use uv for Python versions, dependencies, the
lockfile, build, and publish, with the uv_build build backend.
A repo MUST use ruff to lint and format, ty to type-check, eof-fixer so that every text file ends with exactly one newline, and just as the task runner.
The dev group MUST hold the test dependencies. The lint group MUST hold ruff, ty,
eof-fixer, and any typing stubs. A library's runtime dependencies MUST NOT include either group's
packages.
The justfile MUST provide these recipes, each meeting its contract:
| Recipe | Contract |
|---|---|
install |
Upgrades the lockfile and syncs every extra plus the lint group. The only recipe that touches uv.lock. |
lint |
Rewrites files. Autofix, then type-check. |
lint-ci |
The read-only twin of lint, same checks. |
test |
pytest with arguments passed through and no coverage measured. |
test-ci |
The full run with coverage measured and the XML report written. |
publish |
Builds and publishes the version named by the tag (RL2), authenticating with Trusted Publishing (RL4). |
Why: CI and contributors call only these names, so every repo can be driven the same way.
lint-ci answers without mutating; test stays free of the coverage gate so a targeted run never
meets it (TS3).
A repo MAY add recipes. It MUST NOT rename or repurpose the recipes in JF1.
A repo whose tests need a service (PostgreSQL, Redis, a broker) MAY run test through Docker
Compose. The recipe name and the pass-through of arguments stay as JF1 defines them.
pyproject.toml MUST contain the canonical ruff block.
A repo MAY add ignores to the canonical block, each with a one-line reason. It MUST NOT remove an ignore the canonical block lists.
Why: the canonical ignore list is the minimum the org agrees on.
The ruff configuration SHOULD NOT set target-version.
Why: ruff derives it from requires-python, so a second declaration can only drift.
A repo that still ignores G004, TRY003, or EM102 SHOULD drop them the next time it touches
the ruff block.
Why: across the org they suppress nothing.
ty check MUST run in lint and lint-ci, over the whole repo.
[tool.ty] MUST NOT hold configuration other than src.exclude, and src.exclude MUST list only
directories that are not the package (benchmarks with their own environment, generated code).
Tests MUST run under pytest with testpaths = ["tests"], and with asyncio_mode = "auto" where
asyncio is involved.
Line coverage MUST be 100 %, declared once as [tool.coverage.report] fail_under = 100.
--cov MUST NOT appear in pytest addopts.
Why: pytest-cov applies fail_under whenever coverage is measured, so the gate applies exactly
where --cov is passed: test-ci (and test-branch, where present), never test. Measuring on
every run would gate every run.
A repo MAY measure branch coverage. Branch coverage MUST NOT be the gate.
[tool.coverage.report] exclude_also MUST be ["if typing.TYPE_CHECKING:"]. A repo MUST NOT
exclude a file of the package to reach the number; it deletes or tests the code instead.
[tool.coverage.run] omit MAY list directories that are not the package (benchmarks with their own
environment, generated code), the same carve-out TY2 grants.
A repo MAY exclude a line or block with # pragma: no cover. Each pragma SHOULD carry its reason on
the same line (# pragma: no cover - never invoked; the test only exercises start()); this becomes
MUST on 2026-11-01.
Why: a pragma is an exclusion no configuration lists, so the line itself is the only place a reviewer can see why the code is not tested.
The test matrix MUST include the newest stable CPython minor and that minor's free-threaded build
(3.14 and 3.14t today).
The matrix MUST be every minor from the repo's requires-python floor to the newest, plus the
free-threaded newest, written as a hand-maintained list in the repo's _checks.yml.
Why: a new Python is then a one-line change in every repo, made in one sweep.
A repo MAY set requires-python to what its code needs, and MAY raise it without a recorded
reason. Dependency floors are a separate obligation (CI6).
ci.yml MUST run on push to main and on pull_request, with concurrency cancelling
superseded runs.
scheduled.yml MUST run daily and on workflow_dispatch, running the same checks as ci.yml
except floors (CI6). On a scheduled failure it MUST open or update a tracking issue in the
repo.
Why: a dependency release or a new Python that breaks the build becomes a ticket without anyone watching.
ci.yml and scheduled.yml MUST both call the repo's own reusable _checks.yml, which holds the
jobs CI4 to CI8.
Why: a shared workflow in modern-python/.github was built and rejected
(#95): a workflow_call input is a scalar,
so a service container or an env map has to be flattened into a string, and image knowledge ends
up in the org repo. A change to the jobs is a sweep across the repos instead.
The lint job MUST run just install lint-ci on the repo's floor Python.
The pytest job MUST run just install then just test-ci on every matrix entry, with
fail-fast: false.
The floors job MUST install every direct dependency at its declared floor, wheel-only, on every
matrix entry, then run the suite or an import. It MUST do so in two steps, pinning the floors with
builds allowed and then installing the pins with builds disabled:
- run: uv pip compile pyproject.toml --all-extras --no-deps --resolution lowest-direct -o floors.txt
- run: uv pip install -r pyproject.toml --all-extras --constraints floors.txt --no-buildA repo that runs the suite adds --group dev to the install. It MUST gate every pull request and MUST be skipped on the schedule,
with if: github.event_name != 'schedule' on the job (a called workflow sees its caller's github
context).
A repo MAY run an import smoke test instead of the suite where the floors cannot carry it, and MAY
drop a matrix entry that no upstream wheel covers at the floor (compose2pod drops 3.14t for
PyYAML). A repo with no runtime dependencies and no extras has no floors to test and MAY omit the
job (modern-di).
Why: pytest resolves every dependency at its newest, so without this job the bottom of each
declared range ships untested. It has rotted there before
(compose2pod#126,
faststream-outbox#190).
Wheel-only, because a floor reachable only by compiling an sdist is not one a user installing a
wheel can reach, and without the flag the resolver builds one and reports success. Two steps,
because the flag alone does not fail on such a floor: it makes the wheel-less version ineligible,
and lowest-direct climbs to the lowest version that has a wheel, so the job passes on a version
the repo never declared
(compose2pod#135). Pinning with builds
allowed fixes the declared floor first, and the wheel-only install then fails on it
(compose2pod#139). Every matrix
entry, because wheel coverage is per interpreter. On the pull request, because that is where a floor
breaks: a diff starts using an API newer than the declared floor. lite-bootstrap ran its floors
only on the schedule and shipped such a break to PyPI
(lite-bootstrap#245). A scheduled
run adds nothing: the direct dependencies sit at their floors, which do not change, and the
scheduled pytest already catches the newest releases, which are what users install. Resolving
transitive dependencies at their newest exposes a pull request to an upstream release it did not
cause, but install upgrades the lockfile (JF1), so pytest carries the same exposure.
The links job MUST run lychee with --offline,
remapping this repo's blob/main URLs to the checkout.
Why: it then fails only on a relative link or file path the diff broke.
A repo with a docs site MUST have a docs job running just docs-build (mkdocs build --strict).
The lint job MUST run just adr-check after just install lint-ci. The recipe MUST be this,
byte for byte, and a repo MUST NOT keep a copy of tests/test_adr_citations.py:
adr_check_source := "https://raw.githubusercontent.com/modern-python/.github/main/tests/test_adr_citations.py"
# Tracks main on purpose: the shared check is unpinned.
adr-check:
#!/usr/bin/env sh
set -eu
dir="$(mktemp -d .adr-check.XXXXXX)"
trap 'rm -rf "$dir"' EXIT
curl -fsSL "{{ adr_check_source }}" -o "$dir/test_adr_citations.py"
uv run --no-sync pytest --rootdir=. --noconftest -o addopts= "$dir/test_adr_citations.py"The check asserts that every ADR cited anywhere in the repo, by docs/adr/NNNN-slug.md path or by
bare ADR-NNNN number, exists in docs/adr/ (FL4).
Why: one rule, one file. Twenty-five byte-identical copies of this test drifted within a month
of being written. The recipe tracks main unpinned on purpose: a change to the rule lands once,
with no release and no bump across the org. The trade is that it reaches every repo's next run at
once, so a change to the file in this repo is run against every repo's main before it merges,
and a repo's own pull request sees the fix only once its branch carries it. --rootdir=. makes
the repo the scanned root; --noconftest and -o addopts= keep the repo's conftests and
coverage gate out of a one-file run; the temporary directory sits inside the repo because
plugins such as pytest-alembic reject collected files outside the root. adr_check_source can
be pointed at a file:// path to run an unmerged version of the check.
A release MUST be a tag pushed off green main. The tag name MUST be bare semver (3.4.0) or a
PEP 440 pre-release (2.0.0rc1, 4.0.0a2). The tag object MAY be annotated or signed.
git tag -m "<repo> 3.4.0" 3.4.0 && git push origin 3.4.0pyproject.toml MUST keep version = "0" and MUST NOT be bumped. just publish takes the version
from the tag ($GITHUB_REF_NAME).
release.yml MUST trigger on the tag patterns of RL1, run just publish, and only then
create the GitHub Release with generated notes, flagging a tag that contains a letter as a
pre-release. It MUST NOT run checks of its own.
Why: PyPI is irreversible, so a failed publish must create no Release. The tag is the commitment point, which is why the workflow does not gate on CI.
Publishing MUST authenticate with PyPI Trusted Publishing through the pypi environment. A repo
MUST NOT hold a PyPI token.
release.yml MUST be identical across repos apart from the comment naming the PyPI project.
Why: PyPI does not accept a reusable workflow as a Trusted Publisher, so the file cannot be shared and is copied instead.
Pull request titles SHOULD follow Conventional Commits.
Release prose MAY be added afterwards with gh release edit.
Why: the Release body is GitHub's generated notes from squashed PR titles, so the title is the changelog entry.
CLAUDE.md MUST be exactly one line: @AGENTS.md.
AGENTS.md MUST contain the canonical paragraphs word for word
(line wrapping is free). A repo MAY append sentences after each. Beyond them, AGENTS.md MUST hold
only what an agent cannot infer from the tree.
Why: the canonical paragraphs are the facts an agent gets wrong on first contact and cannot learn from the tree.
CONTEXT.md MUST be the glossary: what the repo is in one or two sentences, then terms that have a
synonym to reject. It MUST NOT hold implementation details.
Decision records MUST live in docs/adr/ as NNNN-slug.md, one paragraph each, and only for
decisions that are hard to reverse, surprising later, and the result of a real trade-off.
LICENSE MUST be MIT.
A repo MUST NOT carry its own CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, or
SUPPORT.md except to override the one it inherits from modern-python/.github.
Every link in README.md MUST be absolute:
https://github.com/modern-python/<repo>/blob/main/<path>, or .../tree/main/<path> for a
directory.
Why: README.md is also the PyPI long description, and PyPI does not rewrite relative links, so
a relative one 404s on the package page.
README.md MUST open with a sentence that states the repo's one-liner (MD1), then what
the package does, then a minimal example. The sentence MAY reword the one-liner and add links.
The GitHub description, the pyproject description, and the repo's row in the org profile MUST be
the same one-liner: purpose-first, about 120 characters at most, no trailing period.
GitHub topics MUST be lowercase and hyphenated, at most 12. A topic for a concept in the shared
vocabulary MUST use its spelling there: python, dependency-injection, di, ioc-container,
modern-di, fastapi, litestar, faststream, sqlalchemy, postgresql, asyncio, docker,
cli, messaging. Other topics, such as the framework a repo integrates with, are the repo's
call. The website field MUST be the docs site, or modern-python.org.
Why: the vocabulary keeps the org's shared concepts findable under one spelling; it cannot list every framework and protocol a repo touches.
keywords MUST mirror the topics, and MUST NOT include dependency injector.
Why: that is another package's name (dependency-injector).
classifiers MUST include Development Status, Intended Audience :: Developers, one
Programming Language :: Python :: 3.X per minor in the matrix (PV2), and
Typing :: Typed. They MUST include Programming Language :: Python :: Free Threading :: 2 - Beta
when the matrix's free-threaded entry is green, and a repo exempt from that entry omits it. They
SHOULD include a Topic where apt. Every string MUST validate against
https://pypi.org/classifiers/. They MUST NOT include a License :: classifier.
Why: the SPDX license = "MIT" key is the declaration, and PEP 639 deprecates pairing it with a
classifier.
[project.urls] MUST use the PyPI labels Homepage, Documentation (only if a docs site exists),
Repository, Issues, and Changelog (the Releases page).
The PyPI distribution name MUST equal the repo name.
| Repo | Exempt from | Why |
|---|---|---|
that-depends |
the core as a whole, except TS2 | The org's most-used package and the only repo with steady external contributor traffic; it keeps its own tooling rather than converging. |
modern-di-arq |
PV1, the 3.14t entry |
Every arq release requires redis[hiredis]<6, and importing hiredis re-enables the GIL (hiredis-py#229). Lift when a hiredis release declares free-threading support. |
modern-di-grpc |
PV1, the 3.14t entry |
grpcio ships no free-threaded wheel and importing cygrpc re-enables the GIL (grpc/grpc#38762). Lift when a grpcio release declares free-threading support. |
lite-bootstrap |
PV1 and PV2, the free-threaded entries | The suite cannot run on a free-threaded build: tests/conftest.py needs the otl extra, and its grpcio, like orjson, pyroscope and the fastmcp stack, ships no free-threaded wheel. A separate job smoke-tests the extras that do install, on 3.13t and 3.14t. Lift when every extra the suite needs installs on the newest free-threaded build. |
semvertag |
RL5 | It is also a GitHub Action (uses: modern-python/semvertag@v0), so its release.yml moves the floating major tag after each stable release. Permanent while the Action is published. |
An exemption is granted by a pull request to this repo that adds the row. Drift that nobody recorded is not an exemption.
A change lands as one pull request to modern-python/.github that updates this page and the
exemptions table together. Repos then converge. The standard carries no version number: the page on
main is the standard.
- An ID is never reused. A removed requirement leaves its ID retired; a reworded one keeps its ID only if its meaning is unchanged.
- A new MUST lands as a SHOULD marked "becomes MUST on ", giving repos time to converge before it joins the core.
The canonical block for RF1.
[tool.ruff]
fix = true
unsafe-fixes = true
line-length = 120
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D1", # docstrings are not forced; a docstring exists when it says something
"D203", # conflicts with D211
"D213", # conflicts with D212
"COM812", # conflicts with the formatter
"ISC001", # conflicts with the formatter
"CPY001", # no per-file copyright header
"FBT", # boolean positional arguments are fine
"TCH", # imports stay real; TYPE_CHECKING-only imports break runtime introspection
]
isort.lines-after-imports = 2
isort.no-lines-before = ["standard-library", "local-folder"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"] # assert is the test idiom; in library code it is a findingThe canonical paragraphs for FL2.
`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth —
`just --list`, or read it.Every link in `README.md` must be absolute: `https://github.com/modern-python/<repo>/blob/main/<path>`,
or `.../tree/main/<path>` for a directory. Never a relative path: `README.md` is also the PyPI long
description, and PyPI does not rewrite relative links, so a relative one 404s on the package page.