Expert · Lesson 82 of 94

Production project structure and dependency management

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Every serious Python project has converged on one shape, and matching it means every tool, teammate, and CI system knows where things are. The package lives under src/<name>/, tests under tests/, and one file, pyproject.toml, describes the project: its name and version, the Python it needs, the packages it depends on, and the settings for every tool. The src layout exists so that tests import the installed package rather than the working directory by accident, which catches packaging mistakes before users do.

Production project structure and dependency management: expenses-cli/ → src/expenses/ — the package → tests/ — pytest → pyproject.toml — metadata, deps, tool config → uv.lock — exact versions of everything → README.md · LICENSE · .gitignore
Production project structure and dependency management · Figure 1 · Open for full size

pyproject.toml has a [project] table with name, version, description, requires-python, and dependencies, a list of requirement strings like httpx>=0.28,<0.29. The [build-system] table names the tool that turns the project into a wheel; hatchling and setuptools are common choices, and uv fills it in for you. When the distribution name (expenses-cli) differs from the import name (expenses), the build backend needs to be told where the package is, which is the [tool.hatch.build.targets.wheel] line. Tool settings live under [tool.<name>]: [tool.pytest.ini_options], [tool.mypy], [tool.ruff]. One file replaces setup.py, setup.cfg, requirements.txt, pytest.ini, mypy.ini, and .flake8.

Section Holds
[project] name, version, description, requires-python, dependencies
[project.scripts] console commands: expenses = "expenses.cli:main"
[project.optional-dependencies] extras users can opt into: pip install expenses-cli[export]
[dependency-groups] dev-only groups: dev = ["pytest", "mypy", "ruff"]
[build-system] how to build the wheel
[tool.*] settings for pytest, mypy, ruff, and the rest

Dependencies come in two kinds. Runtime dependencies are what the code imports, and users need them; declare them with a compatible range so bug fixes arrive but breaking versions do not. Development dependencies, pytest, mypy, ruff, are only for people working on the project; they go in a [dependency-groups] dev group so users never install them. The lockfile, uv.lock, records the exact version of every package including transitive ones, so uv sync produces the identical environment on every machine and in CI; commit it. Ranges in pyproject.toml express intent; the lock records the resolved reality.

Production project structure and dependency management: pyproject.toml: httpx>=0.28,<0.29 (intent) → uv lock → uv.lock: httpx==0.28.1 + every transitive pin (reality) → uv sync on any machine → identical .venv → dependency-groups.dev: pytest, mypy, ruff
Production project structure and dependency management · Figure 2 · Open for full size

Install your project into its environment in editable mode: uv sync does this automatically, and pip install -e . is the manual form. Editable means the environment points at your src folder, so edits are live and tests import expenses exactly as a user would after pip install expenses-cli. If a test only works because the current folder happens to contain the package, the src layout will make it fail, which is the point: the failure appears now, not in a user's environment.

Code

A complete pyproject.toml for the expense library becoming a real project. The tool sections are the ones you met in earlier lessons; the project, scripts, and build tables are new here.

# pyproject.toml
[project]
name = "expenses-cli"
version = "0.1.0"
description = "Parse expense CSVs and produce reports"
readme = "README.md"
requires-python = ">=3.14"
license = "MIT"
authors = [{ name = "Ada Lovelace", email = "ada@example.com" }]
dependencies = [
    "httpx>=0.28,<0.29",
]

[project.scripts]
expenses = "expenses.cli:main"

[project.optional-dependencies]
export = ["openpyxl>=3.1,<4"]

[dependency-groups]
dev = ["pytest==9.1.1", "mypy==2.3.1", "ruff==0.16.6"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/expenses"]   # the import name differs from the project name, so say where the code is

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
strict = true

[tool.ruff]
line-length = 100

The layout, created and inspected. A src package with a version, a console entry point module, and tests beside it. Reading the TOML back with tomllib shows the project sees the same file the tools do.

# layout.py
import tomllib
from pathlib import Path

root = Path("expenses-cli")
for folder in ("src/expenses", "tests"):
    (root / folder).mkdir(parents=True, exist_ok=True)
(root / "src/expenses/__init__.py").write_text('"""Expense reports."""\n\n__version__ = "0.1.0"\n')
(root / "src/expenses/cli.py").write_text(
    "import sys\n\n\ndef main(argv=None):\n    print('expenses', __import__('expenses').__version__)\n    return 0\n\n\n"
    "if __name__ == '__main__':\n    sys.exit(main())\n"
)
(root / "tests/test_version.py").write_text("import expenses\n\n\ndef test_version():\n    assert expenses.__version__ == '0.1.0'\n")
(root / "pyproject.toml").write_text(Path("pyproject.toml").read_text())
(root / "README.md").write_text("# expenses-cli\n")

for path in sorted(p for p in root.rglob("*") if p.is_file()):
    print(path.relative_to(root).as_posix())

meta = tomllib.loads((root / "pyproject.toml").read_text())
print(meta["project"]["name"], meta["project"]["requires-python"], meta["project"]["dependencies"])
print(meta["project"]["scripts"], sorted(meta["dependency-groups"]["dev"]))
Output
README.md
pyproject.toml
src/expenses/__init__.py
src/expenses/cli.py
tests/test_version.py
expenses-cli >=3.14 ['httpx>=0.28,<0.29']
{'expenses': 'expenses.cli:main'} ['mypy==2.3.1', 'pytest==9.1.1', 'ruff==0.16.6']

Why the src layout catches mistakes. Without installing the package, importing from the project root fails; after an editable install into a fresh environment, the tests import it like a user would and the console script exists.

# install_editable.sh
cd expenses-cli
python -c "import expenses" 2>&1 | tail -1
uv venv -q .venv --python 3.14.7
uv pip install -q -e . --python .venv/bin/python
.venv/bin/python -c "import expenses, pathlib; print('imported from', pathlib.Path(expenses.__file__).parts[-3:])"
.venv/bin/expenses
Output
ModuleNotFoundError: No module named 'expenses'
imported from ('src', 'expenses', '__init__.py')
expenses 0.1.0

Locking and syncing. uv lock resolves the ranges to exact versions for everything, uv sync installs the runtime and dev groups from the lock, and uv tree shows what depends on what. The lock is committed; the environment is not.

# lock_sync.sh
cd expenses-cli
uv lock -q --python 3.14.7
grep -c 'name = "' uv.lock | xargs -I{} echo "{} packages locked (including transitive ones)"
grep -A1 'name = "httpx"' uv.lock | grep version
uv sync -q --python 3.14.7
uv tree --depth 1 2>/dev/null | sed -E 's/ v[0-9][^ ]*//' | sort
Output
51 packages locked (including transitive ones)
version = "0.28.1"
expenses-cli
└── ruff (group: dev)
├── httpx
├── mypy (group: dev)
├── openpyxl (extra: export)
├── pytest (group: dev)

Try it yourself

Move the expense library from the Advanced project into this layout: src/expenses/, tests/, and a pyproject.toml with its metadata, httpx as a runtime dependency, and a dev group with the three tools. Run uv lock and uv sync, then uv run pytest and uv run mypy src. Add [project.scripts] pointing at a main in cli.py and confirm uv run expenses works. Commit pyproject.toml and uv.lock, and check .venv is ignored.

Common mistakes

What's next

Your project has a production shape. Next you will build it into a wheel and publish it so anyone can pip install it.

Course overview