- 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
- Formatting:
- 88 characters (ruff default).
- Python Version: 3.12 floor, 3.14 for local development. 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 two Python versions at once:
| Role | Version | Why |
|---|---|---|
| Floor (minimum supported) | 3.12 | Default python3 on Ubuntu 24.04 LTS |
| Local development | 3.14 | Latest stable; also the default on Ubuntu 26.04 LTS |
Ubuntu 24.04 LTS ships Python 3.12 as its system python3. Keeping the floor there
means users on the previous LTS can uv tool install (or pipx install) a tool without
adding a PPA, building Python from source, or upgrading the distro. Nothing in the code
may use a feature newer than 3.12, even though development happens on 3.14.
Considering moving the floor to 3.14 soon. Ubuntu 26.04 LTS ships Python 3.14 (upgraded directly from 3.12 — 26.04 skips 3.13). Once we no longer need to support 24.04, raising the floor to 3.14 lets us drop the compatibility constraint entirely, since it would then match the local development version. Deliberately not done yet: 24.04 is in standard support until 2029, so dropping it now would strand users still on it.
Five knobs enforce the floor. All of them must move together when the floor changes:
| Knob | Where | Value |
|---|---|---|
requires-python |
pyproject.toml |
>=3.12,<3.15 |
ruff target-version |
pyproject.toml |
py312 |
pyright pythonVersion |
pyproject.toml |
3.12 |
vermin --target |
mise.toml (compat-check task) |
3.12- |
| CI matrix | .github/workflows/test.yml |
3.12.x |
vermin is what actually catches accidental use of newer syntax and stdlib APIs —
ruff's and pyright's targets catch some cases but not all, so compat-check is not
redundant. The <3.15 upper bound keeps the package from installing on a Python we
have never tested; it is raised deliberately, not automatically.
The local toolchain version lives in mise.toml ([tools] python), separate from all
of the above. It is intentionally ahead of the floor so problems on new Python
versions surface during development.
CI only tests the floor. The matrix entry for 3.14 is commented out to save CI
minutes, so the newer version is exercised only on developer machines. Uncomment it in
.github/workflows/test.yml if a Python-version-specific bug ever slips through.