How code is grouped, named, and exposed across boundaries. Python's import system is flexible — and the rules below are what keep that flexibility from becoming chaos.
project/
├── pyproject.toml # single source of config: build, deps, tools (12.2)
├── .python-version # pinned interpreter, not system Python (12.12)
├── uv.lock # application locks; libraries declare bounds (12.2)
├── src/
│ └── shop/
│ ├── __init__.py # re-exports the public surface via __all__ (12.3)
│ ├── checkout/ # grouped by feature, two levels deep (12.4, 12.5)
│ │ ├── __init__.py
│ │ ├── endpoints.py
│ │ └── orders.py
│ ├── _internal/ # private; named so it shows in the tree (12.7)
│ │ └── tokens.py
│ └── _generated/ # protobuf/OpenAPI output, never hand-edited (12.9)
│ └── api_pb2.py
└── tests/
├── conftest.py # shared fixtures (12.8)
└── checkout/
└── test_orders.py # mirrors src/shop/checkout/orders.py (12.8)
The src/ layout keeps the in-tree package off the import path so tests run against the installed copy (12.1), with pyproject.toml and .python-version holding all config (12.2, 12.12). Code groups by feature and stays flat — checkout/ owns its endpoints and orders rather than scattering across layer packages (12.4, 12.5). _internal/ and _generated/ name their privacy in the tree (12.7, 12.9), the package's __init__.py re-exports the public surface through __all__ (12.3), and tests/ mirrors src/ module for module (12.8).
Reasoning, step by step:
- The
src/layout puts your package undersrc/<package_name>/instead of at the repo root. The layout prevents accidental imports of the in-source package from the project directory. - Without
src/, running tests from the repo root imports your local code (un-built). Withsrc/, tests run against the installed package — exactly what users get. - Pattern:
project/ ├── pyproject.toml ├── src/ │ └── mypackage/ │ ├── __init__.py │ └── ... └── tests/ └── test_*.py - Pure applications (not published as packages) can skip
src/, but the discipline still pays off when extracting modules later.
Enforcement: review; package directory lives under src/, and CI runs tests against the installed package, not the repo root.
Reasoning, step by step:
- PEP 518 + PEP 621:
pyproject.tomlholds build system, project metadata, dependencies, and tool config. - Don't scatter
setup.cfg,setup.py,tox.ini,.flake8,.mypy.ini. Consolidate. - Build backend:
hatchling(modern, minimal),setuptools(legacy default, fine),poetry-core(if you use Poetry),pdm-backend(PDM). Pick one and stick with it. - Dependencies declared with version constraints (
requests>=2.31,<3). Lock for applications (uv.lock,poetry.lock,pip-compileoutput). Libraries do not lock — they declare bounds.
Enforcement: review; no setup.py/setup.cfg/tox.ini/.flake8 present, config consolidated in pyproject.toml, lockfile committed for applications.
Reasoning, step by step:
- Re-export from
__init__.pyonly the symbols you want as the package's public surface. Use__all__. - Pattern:
# src/mypackage/__init__.py from mypackage.user import User, UserId, UserNotFound from mypackage.payments import Payment, charge __all__ = ["User", "UserId", "UserNotFound", "Payment", "charge"]
- Without re-export, callers import internal paths (
from mypackage.user.model import User) — which then become breaking changes when you reorganize internally. - Empty
__init__.pyfiles create implicit "all submodules are equal" behavior — fine for tests and small packages, not for libraries.
Enforcement: review; library __init__.py declares __all__, and callers import the re-exported surface rather than internal module paths.
Reasoning, step by step:
mypackage/checkout/(api, domain, storage) keeps a feature's code together.mypackage/controllers/,mypackage/services/,mypackage/repositories/scatters each feature across three packages.- Feature-shaped layout localizes change: a new checkout requirement modifies the
checkoutpackage and nothing else. - Cross-feature shared code lives in a sibling
common/sharedmodule. Keep it minimal — every entry pulls every feature toward it.
Enforcement: review; import-linter layer rules forbid feature-spanning controllers/services/repositories packages.
Reasoning, step by step:
mypackage.checkout.api.endpoints.user_lookupis hard to import, hard to remember, and signals over-organization.- Aim: two levels under the package root (
mypackage.checkout.endpoints). - Splits happen when (a) a module exceeds ~500 lines, (b) it has two unrelated responsibilities, (c) circular import pressure forces extraction.
- Flat structure with
__all__discipline beats deep structure with no surface contract.
Enforcement: review; nesting beyond two levels under the package root is flagged, and ~500-line modules trigger a split.
Reasoning, step by step:
- Module A imports from B. B must not import from A — directly or transitively.
- Cycles produce
ImportErrorat startup, or work-by-accident due to import-time-only evaluation, or break under test reordering. - Resolve cycles by: extracting the shared abstraction into a third module both depend on; moving the import to inside a function (last resort, document why); reworking the design.
- Tooling:
import-linterenforces architectural constraints. Define "layer" rules and fail builds on violation.
Enforcement: import-linter independence/layer contracts fail the build on any cycle.
Reasoning, step by step:
- Leading underscore on a name = "internal, don't import this." Not enforced — convention.
__all__defines whatfrom module import *exposes and documents the public surface.- Together: anything not in
__all__and not exposed via__init__.pyre-export is private to the package, regardless of underscore. - Internal modules can also be named
_internal.pyor grouped under_internal/— visible in the file tree.
Enforcement: review; __all__ present on public modules, and underscore-prefixed names stay out of __init__.py re-exports.
Reasoning, step by step:
src/mypackage/checkout/orders.pyis tested bytests/checkout/test_orders.py.- Same structure makes the test for a given module obvious.
tests/conftest.pyfor shared fixtures (and per-subdirectoryconftest.pyfor scoped fixtures).- Integration tests in
tests/integration/, separated by pytest marker (@pytest.mark.integration). Keep them out of the default test run unless they're fast.
Enforcement: review; tests/ mirrors src/ path for path, and the default pytest run deselects the integration marker.
Reasoning, step by step:
- Generated code (Protobuf, OpenAPI, GraphQL clients) goes in a clearly-named generated module:
mypackage._generated/. - Never hand-edit. Change the generator config; regenerate.
- Check generated output in or not in source control depending on generation cost. If it's slow, check it in. If it's fast, generate on every build.
Enforcement: review; generated output isolated under a _generated path, regenerated from config rather than hand-edited.
Reasoning, step by step:
- PEP 420 lets multiple distributions contribute submodules under the same top-level name (
mypackage.plugin_foo,mypackage.plugin_bar, each from a different package). - Useful for: plugin architectures where third parties extend your namespace.
- Not useful for: normal packages. Stick with regular packages (
__init__.pypresent) — fewer surprises.
Enforcement: review; namespace packages permitted only for declared plugin namespaces, otherwise __init__.py is present.
Reasoning, step by step:
- Package root: a README explaining purpose, entry points, and audience.
- Each module starts with a docstring describing its responsibility:
"""Payment processing: card tokenization, gateway dispatch, receipt construction.""" - Don't repeat the module name in the docstring. Describe what it does.
- KDoc-equivalent for Python is the module + function + class docstring. See chapter 14.
Enforcement: ruff D100 (module docstring) plus review for a per-package README.
Reasoning, step by step:
uv(Astral, Rust-based) — modern, fast, replacespip/venv/pip-toolsfor many workflows. Recommended for new projects.poetry— established, opinionated, has its own dependency resolver.pdm— PEP-compliant, supports PEP 582 (__pypackages__).- Pick one per project. Mixing breaks lockfile and venv conventions.
- Pin Python version with
.python-version(works withpyenv,uv,pdm). Don't depend on the developer's system Python.
Enforcement: review; one tool's lockfile and one .python-version committed per project, no mixed lockfiles.
__all__and visibility: chapter 01, chapter 10.- Tests and fixtures location: chapter 11.
- Generated code (e.g., from OpenAPI): chapter 06 (data modeling) for the dataclass/TypedDict split at JSON boundaries.