#Purpose
Rules for a Python 3.12+ codebase that a second engineer can pick up without archaeology. Python will let you skip all of these; the cost arrives at the first refactor.
Framework rules are Backend/fastapi, Backend/django, Backend/flask;
concurrency is Backend/python-async.
#One manifest: pyproject.toml
toml[project]
name = "orders"
version = "1.4.0"
requires-python = ">=3.12"
dependencies = ["fastapi>=0.110,<1", "sqlalchemy>=2,<3"]
[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "S", "N"]
[tool.mypy]
strict = true
requirements.txt,setup.py,setup.cfg,.flake8,.isort.cfgall fold into this one file. Two manifests drift.- Pin a lockfile (
uv.lock) and commit it; keepdependenciesas ranges. A range without a lock is a different build every day; a pin without a range blocks every security patch. uvfor installs and venvs (uv sync,uv run pytest); it is an order of magnitude faster than pip and produces the lock. If pip, thenpip-compile— never hand-edit a lock.
#Layout
graphqlsrc/orders/ # src layout: tests cannot import the package by accident
__init__.py
api/ services/ repos/ models.py settings.py
tests/
conftest.py test_orders.py
pyproject.toml uv.lock README.md
src/layout, installed editable (uv pip install -e .). A flat layout letsimport orderssucceed from the repo root without installing, which hides packaging bugs until CI.- No
utils.py. A module named for what it does (money.py,dates.py) is findable;utilsbecomes a junk drawer by the third commit. __init__.pyre-exports the public surface and nothing else; no side effects at import time (no connections, no config loading).
#Typing
pythondef total[T: (int, Decimal)](items: Sequence[LineItem[T]]) -> T: ... # PEP 695
type OrderId = NewType("OrderId", int) # PEP 695 alias
def load(id: OrderId) -> Order | None: ... # not Optional[Order]
def parse(raw: dict[str, Any]) -> Order: ... # Any only at the untyped edge
mypy --strict(or pyright strict) in CI from day one; retrofitting strictness onto an untyped codebase is a month of work.- Builtin generics (
list[int],dict[str, Any]) andX | None; thetypingspellings (List,Optional) are legacy. Anyis an admission, not a type. It is acceptable at the boundary where data is genuinely untyped, and nowhere inside.Protocolfor structural interfaces you own;ABConly when you need the runtimeisinstance.TypedDictfor dict-shaped data you cannot change; adataclassor Pydantic model for data you can.
#Dataclass or Pydantic
Use a @dataclass | Use a Pydantic model |
|---|---|
| Internal value objects built from trusted data | Anything parsed from a request, file, env, or queue |
| Performance-sensitive hot paths | You need .model_dump()/JSON schema |
| No validation needed beyond types | Constraints, coercion, aliases |
@dataclass(frozen=True, slots=True) is the default internal record: immutable,
memory-light, hashable. Reaching for Pydantic for every internal object taxes
every construction with validation you already did. → Backend/pydantic
#Exceptions
pythonclass OrdersError(Exception): """Base for this package."""
class OrderNotFound(OrdersError): ...
class InsufficientStock(OrdersError):
def __init__(self, sku: str, wanted: int, have: int) -> None:
super().__init__(f"{sku}: wanted {wanted}, have {have}")
self.sku, self.wanted, self.have = sku, wanted, have
- One base exception per package; callers catch the base, tests catch the leaf.
- Carry data as attributes, not only in the message string.
raise ... from errto chain; a bareraiseinsideexceptto re-raise.raise NewError(str(err))throws away the traceback.- Never
except Exception: pass. Catch what you can handle; let the rest propagate to the one place that logs and converts. →Backend/error-handling
#Logging
pythonlog = logging.getLogger(__name__)
log.info("order created", extra={"order_id": order.id, "tenant": tenant.id})
logging.getLogger(__name__)per module; neverprintin library code.- Structured fields via
extra, formatted as JSON by one handler configured in the entrypoint (dictConfig), not in every module. log.exception(...)insideexcept— it attaches the traceback.- Log at
INFOfor business events,WARNINGfor handled failures,ERRORfor unhandled.DEBUGis for you, and off in production.
#Environments
- One virtualenv per project, created by
uv venvorpython -m venv; never install into the system interpreter. .python-versionpins the interpreter; CI uses the same one.- Secrets from the environment, never from a committed file.
.envis for local development and is gitignored. →Security/secret-management
#Tooling in CI
bashuv sync --frozen # fails if the lock is stale
ruff check . && ruff format --check .
mypy src
pytest -q
ruff replaces flake8, isort, pyupgrade and black; one tool, one config, sub-second.
Enable the S (bandit) and B (bugbear) rule sets — they catch real bugs, not
style. → Testing/pytest
#Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
requirements.txt + setup.py + pyproject.toml | Three sources of truth drift | pyproject.toml only |
| Ranges with no lockfile | Non-reproducible builds | Commit uv.lock |
Exact pins as dependencies | Blocks every patch release | Ranges + lock |
| Flat layout | Untested packaging | src/ layout |
utils.py | Junk drawer | Modules named by purpose |
Side effects in __init__.py | Import time does I/O | Move to an entrypoint |
Optional[X], List[X] | Legacy spellings | `X |
Any inside the core | Type checker disabled by stealth | Only at the edge |
| Pydantic for every internal object | Validation tax on hot paths | @dataclass(frozen=True, slots=True) |
except Exception: pass | Errors vanish | Catch what you handle |
raise NewError(str(e)) | Traceback lost | raise ... from e |
print() for diagnostics | Unstructured, unfilterable | logging with extra |
| Installing into the system Python | Version conflicts across projects | A venv per project |
Committed .env | Secrets in history | Environment + gitignore |
#Checklist
- Verify:
pyproject.tomlis the only manifest;requires-pythonset - Verify: Lockfile committed; CI installs with
--frozen - Verify:
src/layout, package installed editable - Verify: No
utils.py; no I/O at import time - Verify:
mypy --strict(or pyright strict) passes in CI - Verify: Builtin generics and
X | Nonethroughout - Verify:
Anyappears only at untyped boundaries - Verify: Internal records are frozen, slotted dataclasses; boundaries use Pydantic
- Verify: One base exception per package; leaves carry data as attributes
- Verify: Exceptions chained with
from; no silentexcept - Verify:
logging.getLogger(__name__)with structuredextra; noprint - Verify:
ruffwithSandBrule sets enabled - Verify:
.python-versionpinned; secrets come from the environment
#Anchors (restated last, read last)
The rules that must hold when you stop, repeated here because the end of the context is what you act on:
-
pyproject.tomlis the only manifest;requires-pythonset - Lockfile committed; CI installs with
--frozen -
src/layout, package installed editable - No
utils.py; no I/O at import time -
mypy --strict(or pyright strict) passes in CI - Builtin generics and
X | Nonethroughout
Before reporting done, prove the module still imports — run the line for this stack and paste its output:
bashpython -c "import <package>" # Python: the package you changed
node -e "require('./<entry>')" # Node CJS, or: node --input-type=module -e "import './<entry>.js'"
go build ./... # Go