Claude Fable 5.1 & GPT-6 Astra packages are live

Python Conventions

Free

Modern Python project conventions — typing that pays for itself, pyproject.toml as the single manifest, uv and ruff, package layout, logging,…

226 lines8.3 KB Deepseek Backend
targetModels
DeepSeek V4DeepSeek V3.2DeepSeek R1DeepSeek V3 FamilyFuture DeepSeek Models
name
python-conventions
category
Backend
description
Modern Python project conventions — typing that pays for itself, pyproject.toml as the single manifest, uv and ruff, package layout, logging, exception design, when a dataclass is enough, and environments that are reproducible.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for DeepSeek: scripts/model-profiles.json -->

#Task boundary

  1. Implement exactly the task as stated. Do not add abstractions, options, config, or files the task did not name.
  2. Comments, identifiers, commit messages and log strings are English only.
  3. Stop when the checklist at the end passes. Do not refactor or "improve" surrounding code.
  4. Every checklist item below is backed by an assertion in a test or by pasted command output, never by a sentence.

#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
  1. requirements.txt, setup.py, setup.cfg, .flake8, .isort.cfg all fold into this one file. Two manifests drift.
  2. Pin a lockfile (uv.lock) and commit it; keep dependencies as ranges. A range without a lock is a different build every day; a pin without a range blocks every security patch.
  3. uv for installs and venvs (uv sync, uv run pytest); it is an order of magnitude faster than pip and produces the lock. If pip, then pip-compile — never hand-edit a lock.

#Layout

graphql
src/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
  1. src/ layout, installed editable (uv pip install -e .). A flat layout lets import orders succeed from the repo root without installing, which hides packaging bugs until CI.
  2. No utils.py. A module named for what it does (money.py, dates.py) is findable; utils becomes a junk drawer by the third commit.
  3. __init__.py re-exports the public surface and nothing else; no side effects at import time (no connections, no config loading).

#Typing

python
def 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
  1. mypy --strict (or pyright strict) in CI from day one; retrofitting strictness onto an untyped codebase is a month of work.
  2. Builtin generics (list[int], dict[str, Any]) and X | None; the typing spellings (List, Optional) are legacy.
  3. Any is an admission, not a type. It is acceptable at the boundary where data is genuinely untyped, and nowhere inside.
  4. Protocol for structural interfaces you own; ABC only when you need the runtime isinstance.
  5. TypedDict for dict-shaped data you cannot change; a dataclass or Pydantic model for data you can.

#Dataclass or Pydantic

Use a @dataclassUse a Pydantic model
Internal value objects built from trusted dataAnything parsed from a request, file, env, or queue
Performance-sensitive hot pathsYou need .model_dump()/JSON schema
No validation needed beyond typesConstraints, 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

python
class 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
  1. One base exception per package; callers catch the base, tests catch the leaf.
  2. Carry data as attributes, not only in the message string.
  3. raise ... from err to chain; a bare raise inside except to re-raise. raise NewError(str(err)) throws away the traceback.
  4. 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

python
log = logging.getLogger(__name__)
log.info("order created", extra={"order_id": order.id, "tenant": tenant.id})
  1. logging.getLogger(__name__) per module; never print in library code.
  2. Structured fields via extra, formatted as JSON by one handler configured in the entrypoint (dictConfig), not in every module.
  3. log.exception(...) inside except — it attaches the traceback.
  4. Log at INFO for business events, WARNING for handled failures, ERROR for unhandled. DEBUG is for you, and off in production.

#Environments

  1. One virtualenv per project, created by uv venv or python -m venv; never install into the system interpreter.
  2. .python-version pins the interpreter; CI uses the same one.
  3. Secrets from the environment, never from a committed file. .env is for local development and is gitignored. → Security/secret-management

#Tooling in CI

bash
uv 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-patternWhy it failsFix
requirements.txt + setup.py + pyproject.tomlThree sources of truth driftpyproject.toml only
Ranges with no lockfileNon-reproducible buildsCommit uv.lock
Exact pins as dependenciesBlocks every patch releaseRanges + lock
Flat layoutUntested packagingsrc/ layout
utils.pyJunk drawerModules named by purpose
Side effects in __init__.pyImport time does I/OMove to an entrypoint
Optional[X], List[X]Legacy spellings`X
Any inside the coreType checker disabled by stealthOnly at the edge
Pydantic for every internal objectValidation tax on hot paths@dataclass(frozen=True, slots=True)
except Exception: passErrors vanishCatch what you handle
raise NewError(str(e))Traceback lostraise ... from e
print() for diagnosticsUnstructured, unfilterablelogging with extra
Installing into the system PythonVersion conflicts across projectsA venv per project
Committed .envSecrets in historyEnvironment + gitignore

#Checklist

  • pyproject.toml is the only manifest; requires-python set
  • 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 | None throughout
  • Any appears only at untyped boundaries
  • Internal records are frozen, slotted dataclasses; boundaries use Pydantic
  • One base exception per package; leaves carry data as attributes
  • Exceptions chained with from; no silent except
  • logging.getLogger(__name__) with structured extra; no print
  • ruff with S and B rule sets enabled
  • .python-version pinned; secrets come from the environment