Packaging & pyproject.toml
A Python package is a directory of modules that can be installed, versioned, and shared. Packaging turns your local code into something anyone can install with pip install mypackage or uv add mypackage. Understanding packaging is essential the moment your project grows beyond a single script or you want to share work with teammates or the public.
This lesson has no Run button — packaging, building, and publishing must run locally:
uv init --lib mypackage && cd mypackage && uv build.
Package layout
Section titled “Package layout”A clean src-layout keeps your source isolated from tests and tooling, preventing accidental imports of uninstalled code. uv init --lib mypackage scaffolds exactly this:
$ uv init --lib mypackage && find mypackage -not -path '*/.git*' | sortmypackagemypackage/.python-versionmypackage/README.mdmypackage/pyproject.tomlmypackage/srcmypackage/src/mypackagemypackage/src/mypackage/__init__.pymypackage/src/mypackage/py.typedflowchart TD root["mypackage/"] --> src["src/"] src --> pkg["mypackage/"] pkg --> init["__init__.py"] pkg --> core["core.py"] pkg --> cli["cli.py"] pkg --> typed["py.typed (empty marker file)"] root --> tests["tests/"] tests --> testcore["test_core.py"] root --> pyproj["pyproject.toml"] root --> readme["README.md"]
py.typed is a real, empty file (PEP 561) — its only job is to exist, telling type checkers that consumers of this package can trust its type hints instead of treating it as untyped.
pyproject.toml
Section titled “pyproject.toml”pyproject.toml is the single source of truth for your package. The [build-system] table specifies the build backend, and [project] declares all metadata pip and PyPI need.
[build-system]requires = ["hatchling"]build-backend = "hatchling.build"
[project]name = "mypackage"version = "0.1.0"description = "A short description of mypackage"readme = "README.md"requires-python = ">=3.14"license = "MIT"authors = []dependencies = [ "httpx>=0.27",]
[project.scripts]mypackage-greet = "mypackage.cli:main"
[project.urls]Homepage = "https://github.com/yourname/mypackage"license = "MIT" is an SPDX license expression string (PEP 639) — the modern form. The older license = { text = "MIT" } table form is deprecated; use a plain string, and add license-files = ["LICENSE*"] if you ship a license file. requires-python is the floor you promise to support — pip and uv refuse to install the package on anything older, so set it to the oldest interpreter you actually test against. distutils was removed in Python 3.12: a build backend such as hatchling (above), setuptools, or uv_build (uv’s own backend — uv init --lib uses it by default) is now required, and setup.py is no longer the entry point.
[dependency-groups] (PEP 735) vs [project.optional-dependencies]
Section titled “[dependency-groups] (PEP 735) vs [project.optional-dependencies]”Both group extra dependencies, but they answer different questions. [project.optional-dependencies] declares installable extras of the published package — a user can pip install mypackage[dev] to opt in. [dependency-groups] (PEP 735, dependency-groups is itself a standard now, not a Poetry/PDM-specific idea) declares development-only groups that are never published — they exist purely for uv sync/uv run in this repo.
[dependency-groups]dev = [ "pytest>=8", "mypy>=1.10",]lint = [ "ruff>=0.16",]uv sync --group dev --group lint # installs main deps + both groups, into .venvuv run pytest -q # uses that environmentReal run — uv sync resolves everything, and the project’s own script entry point (declared in [project.scripts] above) works immediately via uv run, no separate install step:
$ uv sync --group dev --group lintResolved 20 packages in 336ms Building mypackage @ file:///.../mypackage Built mypackage @ file:///.../mypackagePrepared 1 package in 1.03sInstalled 19 packages in 53ms + anyio==4.15.1 + httpx==0.28.1 + mypackage==0.1.0 (from file:///.../mypackage) + mypy==2.3.1 + pytest==9.1.1 + ruff==0.16.8 ...$ uv run mypackage-greet AdaHello, Ada!$ uv run pytest -q. [100%]1 passed in 0.00sPrefer [project.optional-dependencies] for things a consumer of your published package might want (mypackage[cli], mypackage[postgres]); prefer [dependency-groups] for things only contributors to this repo need (dev, lint, docs) — they never ship in the wheel.
Tool configuration lives in pyproject.toml too
Section titled “Tool configuration lives in pyproject.toml too”[tool.ruff]line-length = 100target-version = "py314"
[tool.ruff.lint]select = ["E", "F", "B", "UP"]
[tool.mypy]strict = true
[tool.pytest.ini_options]testpaths = ["tests"]These are not fictional examples — they are this lesson’s own scratch project’s pyproject.toml, and every checker below ran against exactly this config:
$ uvx ruff check src/All checks passed!
$ uv run mypy src/mypackageSuccess: no issues found in 3 source files[tool.ruff]/[tool.mypy]/[tool.pytest.ini_options] mean every contributor and every CI job runs the same configuration automatically — no separate .flake8, mypy.ini, or pytest.ini files to keep in sync, and no risk of a checker silently using its defaults because a config file wasn’t found.
Source files
Section titled “Source files”The public API is re-exported from __init__.py, keeping the import surface clean.
from mypackage.core import greet
__all__ = ["greet"]Business logic lives in dedicated modules such as core.py.
def greet(name: str) -> str: """Return a greeting string.""" return f"Hello, {name}!"[project.scripts] — a real CLI entry point
Section titled “[project.scripts] — a real CLI entry point”[project.scripts] maps a command name to a module:function — uv/pip generate a tiny executable wrapper at install time that imports the module and calls the function. No manual sys.path hacking, no if __name__ == "__main__" shim needed to make it runnable as a command.
# src/mypackage/cli.py -- exercised as `uv run mypackage-greet` aboveimport sys
from mypackage.core import greet
def main() -> None: name = sys.argv[1] if len(sys.argv) > 1 else "world" print(greet(name))Editable install, building, and publishing
Section titled “Editable install, building, and publishing”During development you want code changes to take effect without reinstalling — uv sync/pip install -e . already does this, since uv sync installs the project itself in editable mode by default. When you are ready to distribute, uv build creates both the source distribution and the wheel:
$ uv buildBuilding source distribution...Building wheel from source distribution...Successfully built dist/mypackage-0.1.0.tar.gzSuccessfully built dist/mypackage-0.1.0-py3-none-any.whl
$ ls -la dist/-rw-r--r-- mypackage-0.1.0-py3-none-any.whl-rw-r--r-- mypackage-0.1.0.tar.gzuv publish uploads those artifacts to an index (PyPI by default) — not run here, since it would actually publish a package:
$ uv publish --helpUpload distributions to an index
Usage: uv publish [OPTIONS] [FILES]...
Arguments: [FILES]... Paths to the files to upload. Accepts glob expressions [default: dist/*]
Options: -t, --token <TOKEN> The token for the upload [env: UV_PUBLISH_TOKEN] --trusted-publishing <TRUSTED_PUBLISHING> Configure trusted publishing [possible values: automatic, always, never] --check-url <CHECK_URL> Check an index URL for existing files to skip duplicate uploadsuv publish --token $UV_PUBLISH_TOKEN (a PyPI API token, or --trusted-publishing automatic from CI on GitHub Actions with no token at all) uploads everything in dist/* in one step — no separate twine upload needed. For practice, publish to TestPyPI first with --publish-url https://test.pypi.org/legacy/.
uv.lock vs requirements.txt — again, at the package level
Section titled “uv.lock vs requirements.txt — again, at the package level”The venv-pip lesson covered uv.lock for applications; for a library like mypackage, uv.lock still exists (uv sync writes it) but it locks the development environment — the versions you test against — not what a consumer installing mypackage from PyPI gets, which is governed only by the version ranges in dependencies = [...]. When a downstream tool needs a plain requirements.txt-shaped file instead of uv.lock, uv export converts it:
$ uv export --no-hashes --no-devResolved 20 packages in 3ms# This file was autogenerated by uv via the following command:# uv export --no-hashes --no-dev-e .anyio==4.15.1 # via httpxcertifi==2026.7.22 # via # httpcore # httpx
$ uv export --no-dev# (default: WITH hashes)anyio==4.15.1 \ --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ ...uv export defaults to including hashes — the opposite default of a hand-run pip freeze, and the reason a uv-managed project’s exported requirements.txt is trustworthy in a way a manually maintained one usually isn’t.
Mental Model
Section titled “Mental Model”pyproject.toml has three independent jobs living under one roof: [build-system] tells any tool (“how do I turn this source tree into an installable artifact”) which backend to invoke; [project] is metadata a package index and installer read (name, version, dependencies, entry points); [tool.*] tables are namespaced configuration for whichever development tools you run against the same tree. A build backend never reads [tool.*], and ruff/mypy never read [project.dependencies] — the file is shared, but each tool only looks at its own table.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| src-layout | Prevents accidentally importing uninstalled code during tests | One extra directory level than a flat layout |
[dependency-groups] for dev tools | Never ships in the published wheel; installable independently (--group lint) | Another table to learn, distinct from optional-dependencies |
[tool.*] config centralized in pyproject.toml | One file, one source of truth, easy to diff in review | Some older tools (flake8) still refuse to read pyproject.toml at all |
uv build + uv publish | One tool, one lockfile-aware environment, for the whole lifecycle | Newer workflow — most existing tutorials still show python -m build + twine |
license = "MIT" (SPDX string) | Machine-readable, unambiguous, PEP 639-compliant | The older { text = "MIT" } table form is now deprecated and some tools warn on it |
Common Misconceptions
Section titled “Common Misconceptions”- “
setup.pyis still how you configure a modern package.”distutilswas removed in Python 3.12; apyproject.toml[build-system]+ a backend (hatchling,setuptools,uv_build) is required now, andsetup.py, if present at all, is just a compatibility shim. - “
[project.optional-dependencies]and[dependency-groups]are the same thing with different syntax.” Optional dependencies are installable extras of the published package (pip install mypackage[postgres]); dependency groups are repo-local dev tooling that never ships in the wheel, standardized separately by PEP 735. - “
license = { text = "MIT" }is the correct modern syntax.” That table form is deprecated under PEP 639; the current form is the plain SPDX stringlicense = "MIT". - “
uv.lockfor a library pins what your users get.” It pins your own development/test environment. A published library’s actual installed dependency versions are whatever satisfies the version ranges independencies = [...]at the consumer’s install time. - “A wheel and a source distribution (sdist) are interchangeable outputs of
uv build.” A wheel is a prebuilt, ready-to-install artifact; an sdist is the source tree packaged for building later — installers prefer a compatible wheel and fall back to building from the sdist only when none exists.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated pyproject.toml, check for:
license = { text = "MIT" }— the deprecated table form; should be the SPDX stringlicense = "MIT"(PEP 639).- Leftover
[tool.setuptools]sections (packages,package-dir) in a project whose[build-system]names a different backend (hatchling,uv_build) — copy-pasted boilerplate that the active backend silently ignores, giving false confidence that package discovery is configured. - Runtime dependencies (
requests,httpx) listed only under[dependency-groups] devinstead of[project] dependencies— the package will import-error for every real installer, but tests pass locally because the dev group happens to include it too. - A
pre-commithook that runsruff formatbut notruff check(or vice versa) — formatting and linting are separate ruff subcommands; a hook that only formats letsF401/B006-class bugs merge untouched. dependencies = ["some-lib"]with no version constraint at all in a library meant for others to depend on — an unconstrained floor lets a future breaking release ofsome-libsilently break every consumer.
🐛 Spot the bug
[build-system]requires = ["hatchling"]build-backend = "hatchling.build"
[project]name = "mypackage"version = "0.1.0"license = { text = "MIT" }dependencies = [ "httpx",]
[tool.setuptools]packages = ["mypackage"]
[tool.pytest.ini_options]testpaths = ["tests"]Show the answer
Two bugs. First, license = { text = "MIT" } is the deprecated PEP 639 table form — it should be the plain SPDX string license = "MIT", or a linter/uv build will warn on it. Second, [tool.setuptools] configures a build backend that this file never uses — [build-system] names hatchling, so setuptools’s packages key is silently ignored, most likely copy-pasted from a template that used a different backend; it gives false confidence that package discovery is explicitly configured when hatchling is actually using its own (different) auto-discovery rules. A minor third issue: httpx has no version constraint (httpx>=0.27 would be safer for a published library), letting a future breaking httpx release affect every consumer with no warning.