- Functional Style:
- Avoid mutable accumulator lists (
errors = []; errors.append(...)). Instead, build lists as single expressions using[*(...), *(...)]unpacking, conditional[item] if cond else []fragments, and helper functions that return lists. - Prefer dict/list comprehensions over imperative loops for building collections.
- When a function computes a list from multiple independent branches, compose the result by unpacking sub-expressions rather than mutating a shared list across branches.
- Avoid mutable accumulator lists (
- String Literals:
- Prefer
dedent("""\...""")multiline strings over concatenated single-line strings with\nescapes when the content has meaningful structure (e.g. YAML, config snippets, multi-line templates). Short single-line strings (e.g."key: value\n") are fine as-is.
- Prefer
- Typing: Use type annotations for all functions and methods, including return types. Use
pyrightfor static type checking. - Data Classes:
- All serialized model objects are frozen pydantic dataclasses, immutable once created.
- Other data classes should also be frozen.
- Exception classes are the exception: write them as plain classes that set their attributes
in
__init__, never as@dataclass(frozen=True). Python assigns__traceback__(andadd_notewrites__notes__) while an exception propagates — for instance when it passes through a@contextmanager— and a frozen dataclass turns that intoFrozenInstanceError, replacing the real error with a confusing one.
- Errors: the Python form of structured errors (see
Testabilityin general.md).- An error value is a frozen dataclass with a
kind: StrEnumand the fields that describe it. - An exception carries the same as attributes, and subclasses the built-in its callers already
handle (
OSErrorfor a failed external tool,ValueErrorfor malformed input) so existingexceptclauses keep catching it. __str__stays a plain fallback; the message users see is rendered by the output layer.
- An error value is a frozen dataclass with a
- Imports: import at module level. Where an import has to be deferred — an expensive
module the CLI does not need to start, or a cycle between modules — use a module-level
lazy import/lazy from ... import(PEP 810) rather than an import inside a function, aTYPE_CHECKINGblock, or a module__getattr__.- A lazy import is still a dependency edge: import-linter and pydeps both see it, so the module-boundary contracts keep applying.
- Enable ruff's
TID255(lazy-import-immediately-resolved), which flags a lazy import that module-level code resolves straight away, so the deferral is lost. - When the deferral is the point, such as keeping CLI startup fast, back it with a test
that imports the entry point in a fresh interpreter and asserts the heavy modules are
absent from
sys.modules. Nothing else notices when an eager import creeps back. - An optional extra stays an in-function
try: import ... except ImportError. PEP 810 forbidslazyinsidetry, and a lazy import would move theImportErrorto the first use, where the except clause no longer surrounds it.
- Formatting:
- 88 characters (ruff default).
- Python Version: the latest stable release (3.15), one version for development, CI and users alike. See Python Version Policy below.
- Control Flow
- Prefer match-case over if-elif-else chains
- Prefer comprehensions and built-ins (map, filter) over manual loops when appropriate.
- Avoid
continuein loops, and prefer filtering with comprehensions or built-ins instead. - Prefer single-expression returns over early returns when the logic can be expressed concisely
(e.g.
return bool(x) and all(...)over guard clauses withreturn False). - Prefer explicit if/else syntax over implicit else
- Prefer dict unpacking with a filtered comprehension over if-chains when conditionally
including keys (e.g.
**{k: v for k, v in {...}.items() if v is not None}).
- Console Output: print through Rich, which
Typer already bundles. The Python form of
Presentation at the Edgesin general.md:- Apply indentation at the call site with
textwrap.indent(or a small project helper built on it); formatting helpers return unindented lines. - Rich parses square brackets as markup and silently drops tags it does not recognize, so a
name such as
Hiking [private]prints asHiking. Escape interpolated user text withrich.markup.escapewhere it meets intentional markup, or print withmarkup=Falsewhen the line has none. Escape exactly once: a formatter that escapes what it embeds must be printed with markup on, not escaped again.
- Apply indentation at the call site with
- Module Boundaries: the Python form of
High Cohesion, Low Couplingin general.md. Which package may import which is declared as import-linter contracts inpyproject.toml([tool.importlinter]), and a check task enforces them. The setup is in python-tooling.md.- The contracts are the source of truth for the layering. A generated dependency graph only shows the edges; the project's architecture doc explains why each rule exists.
- When a change needs an edge a contract forbids, first decide whether the edge belongs: move
the code to the layer that may own it rather than bending the contract. Never add an
ignore_importsentry just to make a new edge pass. - When the architecture itself changes, change the contracts deliberately, in the same commit as the restructuring, together with the architecture doc that explains them.
- Core modules do not import
richortyper. A contract names them, which is howPresentation at the Edgesis enforced rather than only stated.
Projects target one Python version: the latest stable CPython release — 3.15 at the time of writing. The minimum supported version, the version developed on and the version CI tests are the same, so there is no compatibility floor to guard and no second interpreter to keep working.
Supporting an older Python costs more than it buys for these projects. Every feature
newer than the floor is off-limits, a separate check (vermin) is needed to enforce
that because ruff's and pyright's targets miss cases, and CI has to test a version
nobody develops on. Meanwhile the tools are installed by people who can get a current
interpreter in one step:
uv tool install <tool>downloads a managed Python when the system has no matching one, so the distro'spython3does not matter.pipx install --python <path-to-python3.15> <tool>works the same way with an interpreter installed by any other means (mise, pyenv, a PPA, Homebrew).
The system python3 of an LTS distribution is therefore not a constraint, and the
projects do not track it.
Four settings name the version. All of them move together, in one change:
| Knob | Where | Value |
|---|---|---|
[tools] python |
mise.toml |
3.15.x — the exact patch release; CI installs it from here |
requires-python |
pyproject.toml |
>=3.15,<3.16 |
ruff target-version |
pyproject.toml |
py315 |
pyright pythonVersion |
pyproject.toml |
3.15 |
The <3.16 upper bound keeps the package from installing on a Python nobody has
tested; it is raised deliberately, together with the other knobs, not automatically.
CI takes its interpreter from mise.toml like a developer machine does, with no
version matrix: there is only one version to test.
Move once every dependency publishes wheels for the new version. uv lock is not the
check: it resolves happily to a release that has only an sdist, and a developer machine
with a compiler then builds it without a word, so the gap shows up later as a failed
install on a user's machine. After raising requires-python, relock with
uv lock --upgrade (an older locked release often predates the new wheels) and ask
for wheels explicitly:
uv sync --no-build --dry-runIt names every package that would have to be built from source. A pure-Python package whose sdist builds without a compiler — PyYAML falling back from its C extension — is acceptable; anything that needs a C, C++ or Rust toolchain is not. Then:
- Move all four knobs, relock (
uv lock,mise lock), and run the full check and test suite. - Run
ruff check --fix; with the newtarget-versionitsUPrules rewrite the idioms the new version makes redundant. - Adopt the release's new features where they remove a workaround or make the code simpler — not everywhere they could apply.
A dependency that publishes no wheels for the latest release holds the project back. Stay
on the newest version that dependency supports, and record it as an exception the way
general.md asks: a comment next to requires-python naming the
dependency, and the condition that retires the exception — usually "move when it
publishes wheels for 3.x". Do not build the dependency from source to get ahead of it:
that trades a version number for a toolchain every user then needs.