A Python project becomes useful to others when it can be installed, imported, and run without knowing your directory layout. Packaging is the contract that makes that possible: metadata, dependencies, entry points, and a build artifact. Doing it early also improves your own workflow, because an editable install makes imports behave exactly as they will for users.
pyproject.toml is the source of truth
Modern Python projects declare build configuration and metadata in pyproject.toml. Here is a complete starting point for the task manager from the earlier project:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "taskman"
version = "0.1.0"
description = "A small persistent command-line task manager"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [{ name = "Aki Tanaka", email = "aki@example.com" }]
dependencies = [
"httpx>=0.27,<1",
]
[project.optional-dependencies]
postgres = ["psycopg[binary]>=3.2,<4"]
[dependency-groups]
dev = [
"pytest>=8,<9",
"pytest-cov>=5,<7",
"ruff>=0.6",
"mypy>=1.11",
]
[project.scripts]
taskman = "taskman.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/taskman"]Hatchling is one good build backend; setuptools, Flit, and PDM are also common. The backend turns your source tree into a wheel and a source distribution. The [project] table is standardized, so the metadata is portable across tools.
dependencies are runtime requirements users get automatically. optional-dependencies define extras: a user who needs PostgreSQL support installs the postgres extra. [dependency-groups] is the newer place for development-only tools; these groups are not published as installable extras. Keep test, lint, and documentation tools there so consumers do not download them.
The src layout and editable installs
The src/ layout prevents accidental imports from the repository root:
taskman/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│ └── taskman/
│ ├── __init__.py
│ ├── __main__.py
│ └── cli.py
└── tests/Install the project in editable mode while developing:
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -e ".[postgres]"
python3 -m pip install --group dev
taskman --helpEditable mode links the installed package to your source, so changes take effect without reinstalling. --group dev installs the development dependency group on recent pip versions; with uv, uv sync --extra postgres reads the same metadata and manages a lock file.
Run imports from a directory other than the repository root to confirm the install is real. If python3 -c "import taskman" works only from the project root, you are importing the folder, not the installed distribution.
Versioning and a single source
Use semantic versioning: increment the patch for fixes, the minor version for backward-compatible features, and the major version for breaking changes. Pre-1.0 projects still benefit from the discipline, even though the rules are looser.
Keep one authoritative version. A static value in pyproject.toml is simplest. If the version also lives in code, derive it at runtime instead of editing two files:
# src/taskman/__init__.py
from importlib.metadata import version
__version__ = version("taskman")That works after installation but fails when running directly from a source checkout without metadata. For libraries, a static version in pyproject.toml and importlib.metadata at runtime is usually enough. For applications that are not imported as libraries, a version file read by the build backend is another option.
Entry points
The [project.scripts] table creates a command installed into the environment:
[project.scripts]
taskman = "taskman.cli:main"After installation, running taskman calls main() from taskman/cli.py. The console-script wrapper converts the return value into the process exit status, so return 0 on success and a non-zero integer on failure. Keep main importable and free of side effects at module import time.
Build and inspect the artifacts
Build the distributions in an isolated environment:
python3 -m pip install build
python3 -m build
ls dist/You should get a wheel and a source tarball. Inspect both before publishing:
python3 -m zipfile -l dist/taskman-0.1.0-py3-none-any.whlCheck that the package modules are present, that tests are not shipped unintentionally, and that README and license files are included as intended. Install the wheel into a clean virtual environment and run the command:
python3 -m venv /tmp/taskman-check
/tmp/taskman-check/bin/python -m pip install dist/taskman-0.1.0-py3-none-any.whl
/tmp/taskman-check/bin/taskman --helpThis catches missing packages, bad entry points, and dependencies that were only present in your development environment.
Publishing basics
Publishing is a release step, not a development step. Create accounts on TestPyPI and PyPI, enable two-factor authentication, and use an API token scoped to the project. Never commit tokens or put them in shell history. Upload to TestPyPI first:
python3 -m pip install twine
twine check dist/*
twine upload --repository testpypi dist/*Install from TestPyPI in a clean environment to verify the metadata and files. Then upload the same artifacts to PyPI. Trusted publishing through GitHub Actions avoids long-lived tokens entirely. Once a version is published, it cannot be replaced; yank it and release a new patch version instead.
Reproducible environments
A list of direct dependencies is not a lock file. Two installs a month apart can resolve to different transitive versions. Use a lock file for applications: uv.lock with uv sync --locked, or a compiled requirements file with hashes for pip. Libraries should publish reasonable version ranges and test the lower and upper bounds rather than pinning every transitive dependency for consumers.
Common packaging mistakes:
- Importing the source directory and assuming the package is installable.
- Forgetting
__init__.py, so the directory is not a regular package. - Placing data files outside the package and not declaring them.
- Letting the distribution name and import package name drift without reason.
- Publishing from a dirty working tree with uncommitted changes.
- Including secrets, virtual environments, or build output in the wheel.
Practice
- Add
pyproject.toml, a README, and a license to the task manager. Install it editable and run the console command from a different directory. - Move development tools into
[dependency-groups]and add apostgresextra with a database driver. Verify that a plain install does not include the extra. - Build a wheel and source distribution. Inspect both, install the wheel in a clean environment, and run the CLI.
- Add a version test that asserts
taskman.__version__matches the installed distribution metadata. - Publish a throwaway
0.0.1release to TestPyPI using a scoped token, install it in a clean environment, then delete the local token and document the release steps in the README.