Python
Concrete Python idioms that make the architecture skill's principles
correct-by-construction: the how in Python, not the agnostic why or the
enforceable config - see routing.
Routing - who owns what
| Concern | Owner | This skill |
|---|---|---|
| Agnostic principles (functional core/shell, ports, errors as values, observability, idempotency, the config lifetimes) | architecture (references/configuration-lifecycle.md for config) |
states the Python idiom + why, points here |
ruff format/lint, basedpyright recommended, no-Any, vulture, import-linter / tach contracts, purity bans, pytest strictness |
mechanical-enforcement (references/python.md) |
names the idiom, points there for config |
| Test strategy, layers, fakes-not-mocks, property tests | testing |
Python specifics only (pytest, hypothesis, no mock.patch) |
| Coverage thresholds, mutation, CI/hook enforcement | test-coverage |
- |
| Structured logging | logging-best-practices (references/python.md) |
points there |
Rule: state the idiom and its why here, point out for the rest, and never copy their tables.
Adapt first
Read the repo first. These are defaults for greenfield or where the repo has no convention - not a migration mandate.
Does the repo already have a convention for this concern?
|-- errors -> its exception hierarchy or Result/returns convention; no rival
|-- schema -> its parser (pydantic / msgspec / attrs+cattrs); no second one
|-- typing -> its checker (mypy vs basedpyright) and strictness first
|-- modules -> its src-layout and import style before "fixing" it
|-- tests -> its runner (pytest) and double strategy
`-- none -> the defaults here; integrate, don't migrate
Priority when rules pull apart: correctness/safety > existing conventions > better local design > avoiding broad migrations > documenting the trade-off. A new code path follows these standards; an unrelated change migrates nothing.
Version floor. Every snippet is Python 3.12+ (PEP 695 generics, type
aliases) and 3.14 is the default for new work; references/toolchain.md owns the
floor, the idiom-to-version table and every dated library fact. Much of the
Cosmic-Python canon (raw dataclasses, exception-first flow) predates pydantic v2,
match and basedpyright - prefer the modern idiom.
Core idioms
Errors as values
Expected failures - domain, parsing, auth, persistence - belong in the return type, not a raised exception: a signature is the only failure contract a caller reads.
def find_active(email: EmailAddress) -> Result[ActiveUser, UserLookupError]: ...
Which Result is a type-checker decision, raising is for defects, and a
boundary exception translates at the shell. See references/errors.md.
Composing fallible steps
Chain with map_ok/and_then while a step needs only its predecessor, ladder
with early returns once one needs more, and let private steps raise so the public
seam translates once - each error mapped into one declared channel, or it grows
a member per step. See references/errors.md.
Make signatures total and honest
Constrain the input to a parsed or NewType value, or widen the output to
Result; constraining is better, deleting the branch for every caller. None is
no error channel, and a None default hides two operations in one. See
references/parsing.md.
Parse, don't validate
Turn untrusted or loosely-typed input into domain types once at the boundary and
keep the refined type - rows and config included, since nothing inbound is
trusted. Boundary models carry ConfigDict(strict=True, extra="forbid"), a
control no linter substitutes for. See references/parsing.md.
Make illegal states unrepresentable
A lifecycle is a union of frozen dataclasses, not a bag of is_x booleans, and a
closed set is a Literal or StrEnum: class patterns plus assert_never
exhaust the union with no tag field.
match inv:
case Draft(): ...
case Sent(sent_at=at): ...
case _: assert_never(inv) # new variant -> type error
Named options or domain types replace boolean behaviour flags. See
references/modeling.md.
Value objects and entities
@dataclass(frozen=True, slots=True, kw_only=True) is the house value object -
value-equality, hashability, no positional swaps - and an entity is
@dataclass(eq=False) with a hand-written __eq__ over its stable id. Every
flag also charges: frozen blocks rebinding, not mutation. See
references/modeling.md.
Branded primitives - and where the advice stops
typing.NewType brands statically only and every parser erases it, so it fits an
id whose one rule is "do not mix these up", while an Annotated alias carries a
rule the boundary applies; a plain str/int is right where a wrapper is
ceremony. See references/parsing.md.
Deep modules and a public surface
One package per bounded context under src/, __init__.py its only importable
surface marked by __all__ or an X as X re-export, absolute imports
throughout. Keep it thin and free of import-time side effects: importing a
package executes every submodule it re-exports. See references/modules.md.
Protocol over ABC for ports
A typing.Protocol naming the narrowest shape a caller needs is the default
port, since an adapter satisfies it by shape with no subclassing, and a
single-method port is a plain Callable. abc.ABC is for nominal enforcement
or shared behaviour only. See references/ports-persistence.md.
Persistence ignorance
Domain classes stay plain objects with no ORM base class - SQLAlchemy's
imperative mapping makes the ORM import the model, not the reverse - and
aggregates link by id, never by embedding. Watch SELECT N+1 on a lazy-loaded
graph. See references/ports-persistence.md.
Configuration and a composition root that is a scope
Parse config once at the composition root into a frozen typed value
(pydantic-settings, SecretStr) and refuse to start on bad config; nothing
outside bootstrap() touches os.environ. The root is an
@asynccontextmanager over an AsyncExitStack, which releases in exact reverse
of acquisition. See references/ports-persistence.md.
Resource and transaction boundaries
A with block carries a transaction or resource scope - the Unit of Work is
with uow:. Rollback is the default, so the only path that persists is total
success plus an explicit commit(). See references/ports-persistence.md.
Structured concurrency
Every task has an owner: spawn only inside asyncio.TaskGroup or an anyio task
group, since a bare create_task is weakly referenced and can be collected
before it runs. A child's failure arrives wrapped in an ExceptionGroup even
when alone, so unwrap at the shell. See references/concurrency.md.
The Any / cast / type-ignore discipline
Config is mechanical-enforcement's; the idioms: no bare Any, cast() only
under a # SAFETY: comment, a rule-coded # pyright: ignore[reportX] so
suppressions expire, assert_never on exhaustive matches, Self, @override,
@final and @warnings.deprecated where they apply, and
Sequence/Mapping/Iterable parameters against concrete returns. See
references/conventions.md.
References
references/errors.md-Resultshape, composition, error naming, translation at the shell.references/parsing.md- schema ladder, strict boundaries, smart constructors, brands, optionality.references/modeling.md- record-type chooser, illegal states, the house dataclass, entities.references/modules.md- deep modules, layout, public surface, import-time effects, cycles.references/ports-persistence.md- Protocol vs ABC, persistence, config, composition root, retries, clock.references/concurrency.md- task ownership, exception groups, cancellation, streams, back-pressure.references/toolchain.md- floor and default, idiom-to-version table, uv, free-threading, library facts.references/conventions.md-Any/cast/type-ignore, narrowing, collections, pytest.