Packaging & project structure
The src layout, pyproject.toml, editable installs, console scripts, building wheels and essential tooling.
A single script is fine for small jobs. But once your code grows — multiple modules, tests, dependencies, a command-line tool, teammates — you need a project structure that every Python developer recognises, and a way to package it so it can be installed with pip. This lesson walks through a modern, professional layout built around pyproject.toml, then builds and installs a real package.
A recommended layout#
We'll build a small tool called textstats that reports word statistics. Here's the structure:
Why this shape?
src/layout: your package lives insrc/textstats/, not at the project root. That means Python can only import it once it's installed, so your tests exercise the package exactly as users will get it — missing files and broken imports show up immediately. It's the layout recommended by the Python Packaging User Guide.- Tests outside the package, in
tests/, so they aren't shipped to users. - One
pyproject.tomlreplaces the oldsetup.py,setup.cfgand many tool-specific config files.
The code#
pyproject.toml#
This one file describes the project:
The sections:
[build-system]says which build backend turns your source into installable files. Hatchling, setuptools, Flit, PDM-backend and uv's backend are all fine; we use Hatchling.[project]is the standard metadata (PEP 621): name, version, Python requirement and runtime dependencies. Use loose lower bounds here (requests>=2.32), not exact pins — exact pins belong in an application's lock file.[project.optional-dependencies]defines extras, installed withpip install -e ".[dev]".[project.scripts]creates console commands: after installing, typingtextstatsrunstextstats.cli:main.[tool.*]sections configure pytest, ruff, mypy and other tools in the same file.
Developing: editable installs#
From the project root, inside a virtual environment:
-e (editable) installs a link to your source folder, so edits take effect immediately without reinstalling. Now the package imports from anywhere, the tests pass, and the command exists:
You can also run the CLI as a module: python -m textstats.cli README.md.
Building distributions#
To share your package, build it into two standard files:
- The sdist (
.tar.gz) is a source archive. - The wheel (
.whl) is a ready-to-install archive — pip prefers wheels because installing them is fast and needs no build step.py3-none-anymeans "pure Python, any OS".
Anyone can now pip install dist/textstats-0.1.0-py3-none-any.whl. To publish on PyPI so the world can pip install textstats, create an account at pypi.org, generate an API token, and upload with twine upload dist/* (try TestPyPI first). Many projects publish automatically from CI using PyPI's trusted publishing.
Applications vs libraries#
The layout above suits libraries and CLI tools. For applications (a web service, a data pipeline), the same ideas apply, with a few differences:
- Pin exact versions for reproducible deployments — with a lock file (
uv lock,pip-tools'requirements.txt, Poetry'spoetry.lock). - Keep configuration in environment variables, not in code.
- A typical web app adds folders like
src/app/api/,src/app/models/,src/app/services/, plus aDockerfile.
The guiding principle: separate what the app does (pure business logic, easy to test) from how it talks to the world (HTTP, databases, files).
Tooling every project should have#
Run them all in CI (e.g. GitHub Actions) on every pull request, so problems are caught before they reach the main branch.
A good .gitignore for Python starts with:
Versioning#
Use semantic versioning: MAJOR.MINOR.PATCH. Bump PATCH for bug fixes, MINOR for backwards-compatible features, MAJOR for breaking changes. Keep a CHANGELOG.md, and tag releases in git (git tag v0.1.0).
Common mistakes#
- Putting the package at the project root and running tests from there — tests pass locally but the installed package is broken. Use the
src/layout. - Committing
.venv,dist/or__pycache__. - Pinning exact versions in a library's
dependencies, which causes conflicts for users. Pin in applications, not libraries. - Forgetting
__init__.pyre-exports, forcing users to import from deep internal modules. - Mismatched names: the distribution name (
pip install textstats) and import name (import textstats) should match whenever possible. - Secrets in the repository — use environment variables and
.env.example.
What's next#
Your code is well structured. The final lesson pulls everything together: Pythonic style, performance and where to go next.
Check your understanding
Quick quiz
1.Which file is the modern standard for declaring a Python project's metadata, dependencies and tool settings?
2.What is the main benefit of the
src/layout?3.What does
pip install -e .do?
Finished reading?
Mark this lesson complete to track your progress.