What you'll learn
- Lay out a project the way production Python projects are laid out: a
srcpackage,tests, andpyproject.toml - Declare metadata and dependencies in
pyproject.toml, the single configuration file modern tools share - Separate runtime dependencies from development ones and lock them with
uv - Install your own project in editable mode so tests and tools import it like any other package
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.

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.

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"]))
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
ModuleNotFoundError: No module named 'expenses'
imported from ('src', 'expenses', '__init__.py')
expenses 0.1.0Locking 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
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
- Runtime and dev dependencies in one list. Users then install pytest and mypy; put tools in a
devgroup. - Pinning
==inpyproject.tomlfor a library. Exact pins belong in the lockfile; the project declares compatible ranges so it can coexist with others. - Not committing the lockfile. Without it, every machine resolves its own versions and "works on my machine" returns.
What's next
src/<package>/,tests/, andpyproject.tomlare the standard shape; thesrclayout makes tests import the installed package.pyproject.tomldeclares metadata, dependency ranges, dev groups, scripts, and every tool's settings in one file.uv lockrecords exact versions,uv syncreproduces them; commit the lock, ignore the environment.
Your project has a production shape. Next you will build it into a wheel and publish it so anyone can pip install it.