Project layout — src/, tests/, config, and secrets
A layout that survives the jump from script to package to service, plus config and secret patterns.
Why this matters
The first script you write fits in one file. The fifth grows hairy. The fiftieth requires structure — or you can't onboard anyone, run tests, or publish to PyPI. Senior engineers organise code so it scales from prototype to package without rewrites. This lesson installs that muscle memory: src/ layout, modules, packages, __init__.py, relative imports, and how sys.path actually works.
Learning objectives
- Distinguish modules and packages.
- Apply the
src/layout for installable projects. - Use absolute and relative imports correctly.
- Understand
__init__.py, namespace packages, and import side-effects. - Use
__main__.pyfor CLI entry points.
1. The four levels of organisation
script.py single file
↓
module (a .py file) grouped functions/classes
↓
package (a directory) group of modules
↓
distribution (a PyPI release) installable; has pyproject.tomlYou can stop at any level. A 100-line tool stays a single script. A 10k-line project becomes a distribution.
2. The canonical src/ layout
my-project/
├── pyproject.toml
├── README.md
├── .gitignore
├── .python-version
├── uv.lock
├── src/
│ └── my_project/
│ ├── __init__.py
│ ├── core.py
│ ├── utils.py
│ └── cli.py
└── tests/
├── __init__.py
├── test_core.py
└── test_cli.pyWhy src/?
- Forces installation (
uv pip install -e .) before importing — catches "works in repo, breaks when installed" bugs. - Keeps tests from accidentally importing the un-installed local copy.
- Standard since ~2019; recommended by the Python Packaging Authority.
The other layout (top-level package, no src/) is acceptable for tiny tools but less safe.
3. Modules — a .py is a module
# utils.py
def slugify(s: str) -> str:
return s.lower().replace(" ", "-")Use it:
# main.py
import utils # whole module
from utils import slugify # specific name
from utils import slugify as slug # renameEvery module has dunder attributes:
__name__: the import name; equals"__main__"when the file is run directly.__file__: path to source.__doc__: the docstring (first triple-quoted string).__package__: containing package name.
The if __name__ == "__main__": idiom
def main() -> None:
print("running as script")
if __name__ == "__main__":
main()When imported, __name__ is "utils". When run via python utils.py, it's "__main__". The idiom lets a module be both library and script.
4. Packages — a directory with __init__.py
my_project/
├── __init__.py <- makes the directory a package
├── core.py
└── utils.pyImport:
import my_project
from my_project import core
from my_project.utils import slugifyThe package itself is a module — the code in __init__.py runs once on first import.
What goes in __init__.py?
Three common patterns:
Empty (most files):
# (empty)Re-export public API:
# __init__.py
from my_project.core import Engine, Item
from my_project.utils import slugify
__all__ = ["Engine", "Item", "slugify"]So callers can do from my_project import Engine instead of from my_project.core import Engine.
Version constant:
__version__ = "1.2.3"Subpackages
my_project/
├── __init__.py
├── api/
│ ├── __init__.py
│ ├── auth.py
│ └── users.py
└── core/
├── __init__.py
└── engine.pyfrom my_project.api.auth import loginNamespace packages (no __init__.py)
Python 3.3+ allows namespace packages that span multiple directories without an __init__.py. Useful for plugin systems. For application code, prefer regular packages with explicit __init__.py.
5. Absolute vs relative imports
Absolute (preferred):
# my_project/api/users.py
from my_project.core.engine import EngineRelative (also fine inside a package):
# my_project/api/users.py
from ..core.engine import Engine # two dots = parent package
from .auth import login # one dot = same packageWhen use which
- Public packages → absolute (clear, robust to refactoring).
- Tightly-coupled subpackages → relative (one fewer thing to rename when the top-level renames).
Don't mix from foo.bar import baz with from .bar import baz in the same package without a reason; pick a style.
Forbidden: relative import outside a package
# random_script.py at the top of repo
from .utils import slugify # ImportError: attempted relative import with no known parent packageRelative imports only work inside installed/imported packages.
6. How sys.path and import work
When you write import foo, Python:
- Checks
sys.modules(cache). - Searches
sys.pathdirectories forfoo.pyorfoo/__init__.py. - Executes the module top-to-bottom.
- Stores the resulting module object in
sys.modules. - Binds the name
fooin the importer's namespace.
import sys
print(sys.path)
print(sys.modules.keys())sys.path order (rough):
- The script's directory (or
''for the cwd in REPL). PYTHONPATHenvironment variable.- Installed site-packages (the venv).
The "I have two utils.py" bug
Your script imports utils. Python finds ./utils.py (top of sys.path) instead of the package's my_project/utils.py. Result: bizarre behaviour. Fix: install your project (uv pip install -e .) and import via the package name.
Reload during development
If you change a module mid-REPL session, import returns the cached version. Use:
import importlib
importlib.reload(mymod)Or just restart the REPL. (Jupyter has %autoreload.)
7. Entry points and python -m
You can run any module / package as a script:
uv run python -m my_project # runs my_project/__main__.py
uv run python -m my_project.cli # runs my_project/cli.py__main__.py is the convention for "what this package does when run as a script":
# my_project/__main__.py
from my_project.cli import main
main()Pros over a top-level script:
- Works regardless of cwd.
- Doesn't pollute
sys.path[0]with the project's parent dir. -mresolves the venv correctly.
For installed CLIs, declare a console script entry point in pyproject.toml:
[project.scripts]
my-cli = "my_project.cli:main"After uv pip install -e ., the command my-cli is on PATH and calls my_project.cli.main().
8. Best-practice file structure for different sizes
Tiny tool (≤ 100 lines)
tool.py
README.md
pyproject.tomlSmall library (one package, no subpackages)
my-lib/
├── pyproject.toml
├── README.md
├── src/my_lib/
│ ├── __init__.py
│ └── core.py
└── tests/
└── test_core.pyMedium app (subpackages, CLI, config)
my-app/
├── pyproject.toml
├── README.md
├── src/my_app/
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ ├── config.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ └── auth.py
│ ├── core/
│ │ ├── __init__.py
│ │ └── engine.py
│ └── data/
│ ├── __init__.py
│ └── models.py
├── tests/
│ ├── unit/
│ └── integration/
├── scripts/
│ └── seed.py
├── docs/
└── docker/
└── DockerfileBig monorepo
Multiple packages under packages/, shared pyproject.toml, workspaces.
9. Imports etiquette
PEP 8 standard import order, grouped with blank lines:
# 1. Standard library
import os
import sys
from pathlib import Path
# 2. Third-party
import httpx
import pydantic
from rich.console import Console
# 3. First-party (your own packages)
from my_project.core import Engine
from my_project.utils import slugify
# 4. Local (relative)
from .helpers import build_requestruff (with I rule) sorts these automatically.
Avoid:
from module import *(pollutes namespace, breaks tooling).- Importing inside functions (only when needed to break circular imports or defer slow imports).
- Re-exporting half a package's internals from
__init__.py. Pick a deliberate public API.
10. Circular imports — and how to break them
# a.py
from b import B
class A: pass
# b.py
from a import A
class B: passBoom: ImportError.
Fixes (in order of preference):
- Restructure — move the shared dependency to a third module.
- Move the import inside the function that needs it.
- Use
TYPE_CHECKING:pythonfrom typing import TYPE_CHECKING if TYPE_CHECKING: from b import B # only seen by type-checker, not at runtime class A: def use(self, b: "B") -> None: ...
Restructuring is usually the right answer. Circular imports often signal a design issue.
Hands-on lab (1 hour)
- Convert a single-file
tool.pyinto asrc/-layout package. - Add a
cli.pyexposing amain(); wire[project.scripts]somy-cliworks afteruv pip install -e .. - Add
__main__.pysopython -m my_projectworks. - Split into
core/andutils/subpackages; update imports to be absolute. - Intentionally introduce a circular import; fix it with
TYPE_CHECKING. - Add
__all__to one module; verifyfrom x import *only exposes those names. - Bonus: add a namespace package extension (a separate repo that contributes more modules under the same top-level name).
Common pitfalls
- Forgetting
__init__.pyand wondering why imports fail. - Running
python my_project/cli.py(often breaks imports) instead ofpython -m my_project.cli. - Putting business logic at module top-level (runs on import); only definitions and lightweight constants belong there.
- Catching
ImportErrorto "make it work both ways" — usually masks real bugs. - Importing the test package from production code (sneaks past CI, fails on PyPI install).
Self-check
- Module vs package vs distribution.
- What does
if __name__ == "__main__":do? - Difference between absolute and relative imports.
- Why use the
src/layout? - How does Python find a module on import?
References
- PEP 328 — Imports: Multi-Line and Absolute/Relative.
- PEP 420 — Implicit Namespace Packages.
- PEP 8 — Style Guide for Python Code (imports section).
- Python Packaging User Guide: https://packaging.python.org/en/latest/tutorials/packaging-projects/
- "The Definitive Guide to Python
srcLayout", Hynek Schlawack.
Sign in to save your progress and earn badges.