Python Project Standards
Python will let a service become a ball of mud faster than any other language on this list, because nothing stops one module importing another. The discipline that matters is not style — ruff settles that in a second — it is dependency direction, and it has to be enforced by a tool or it will not hold.
Toolchain
- uv for environments, dependency resolution and locking.
uv syncis reproducible;pip install -r requirements.txtis not. pyproject.tomlas the single config file. Nosetup.py, nosetup.cfg, no.flake8.- ruff for both linting and formatting. It replaces flake8, isort, black, pyupgrade and most plugins, and it is fast enough to run on save.
- mypy or pyright in strict mode. Pick one and put it in CI. A type checker that only runs in an editor checks nothing.
- Pin the Python version in
requires-pythonand in CI, and make them match.
Layout
Use a src layout — src/<package>/ — so tests import the installed package rather than the
working directory. Flat layout silently tests files that would not ship.
Then choose the internal shape deliberately:
- Flat modules for a library or anything under roughly ten modules. Feature directories at that size are ceremony.
- Feature packages —
src/app/<feature>/{router,service,repository,schemas}.py— for a service with multiple entry points and more than one contributor. The unit that owns its own data access is the feature, not the layer.
Do not organise by technical layer at the top level (models/, views/, services/). It scales
badly: every change touches every directory, and nothing tells you what the application does.
__init__.py stays empty in feature packages. Re-exporting the whole subtree from __init__
creates import cycles and makes every import pull the world.
The ruff rules that matter
Enabling every rule and then scattering noqa teaches the team to ignore the linter. Select
deliberately:
E,F,W,I— the baseline plus import sorting.C901(complexity),PLR0915(too many statements),PLR0912(too many branches),PLR0913(too many arguments). These are the budget rules and they are the point.TID252to ban relative imports beyond the current package, which is how cycles start.B(bugbear) for mutable default arguments and the other genuine traps.RUF100so an unusednoqais itself an error — otherwise suppressions accumulate forever.ARGfor unused arguments,PTHto push offos.path,ASYNCif the codebase is async.
Set [tool.ruff.lint.pylint] max-statements = 50 and keep files under 300 lines as a budget: over
budget, a file either splits or carries a one-line header saying why it does not.
Typing
- Strict mode on.
disallow_untyped_defs,warn_return_any,no_implicit_optional. - No bare
Any. If a shape is genuinely unknown, useobjectand narrow, so the checker forces the check. - No
# type: ignorewithout an error code and a reason —# type: ignore[arg-type] # lib is untyped. Setenable_error_code = ["ignore-without-code"]. Protocolover ABC for anything you own. Structural typing means the implementation does not have to import the abstraction, which keeps the dependency arrow pointing the right way.- pydantic at the boundary only — request bodies, config, external API responses. Inside the domain use dataclasses or plain types; pydantic validation in a hot loop is a real cost.
from __future__ import annotationsat the top of every module on Python 3.11.
Enforce dependency direction
This is the piece almost no Python repo has, and the reason services rot. Add import-linter
contracts to pyproject.toml and run lint-imports in CI:
- A layers contract for the vertical direction:
apimay importservice,servicemay importrepository, and never the reverse. - An independence contract listing the feature packages, so no feature can import a sibling.
- A forbidden contract keeping the domain free of framework imports — no
fastapiordjangoinsidesrc/app/domain.
A contract that fails the build is worth more than a paragraph in a README that everyone has agreed to.
Verification gate
-
uv sync --frozensucceeds — the lockfile is current. -
ruff check .andruff format --check .clean. -
mypy src/(orpyright) clean in strict mode, with no rule relaxed to get there. -
lint-importspasses and no contract was weakened. -
pytestpasses; no test was skipped or markedxfailto reach green. - Paste the command output rather than summarising it.