---
name: python-project-standards
description: Sets up a Python project with uv, a src layout, a ruff selection that catches real complexity, strict type checking, and import-linter contracts that enforce dependency direction in CI. Use when starting a Python service, restructuring packages, tightening typing, or reviewing Python project configuration.
license: MIT
compatibility: Python 3.11+. Requires permission to run uv, ruff and mypy or pyright.
metadata:
  category: coding
  version: "1.0.0"
---

# 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 sync` is reproducible;
  `pip install -r requirements.txt` is not.
- **`pyproject.toml` as the single config file.** No `setup.py`, no `setup.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-python` and 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.
- `TID252` to ban relative imports beyond the current package, which is how cycles start.
- `B` (bugbear) for mutable default arguments and the other genuine traps.
- `RUF100` so an unused `noqa` is itself an error — otherwise suppressions accumulate forever.
- `ARG` for unused arguments, `PTH` to push off `os.path`, `ASYNC` if 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, use `object` and narrow, so the checker forces
  the check.
- **No `# type: ignore` without an error code and a reason** — `# type: ignore[arg-type]  # lib is
  untyped`. Set `enable_error_code = ["ignore-without-code"]`.
- **`Protocol` over 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 annotations` at 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: `api` may import `service`, `service` may import
  `repository`, 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 `fastapi` or `django`
  inside `src/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 --frozen` succeeds — the lockfile is current.
- [ ] `ruff check .` and `ruff format --check .` clean.
- [ ] `mypy src/` (or `pyright`) clean in strict mode, with no rule relaxed to get there.
- [ ] `lint-imports` passes and no contract was weakened.
- [ ] `pytest` passes; no test was skipped or marked `xfail` to reach green.
- [ ] Paste the command output rather than summarising it.
