Python Packages, pip, and pyproject.toml
Install, pin, and publish — a practical tour of pip, PEP 621 pyproject.toml, and building a tiny library you can share on TestPyPI.
What you'll learn
- ✓How pip resolves and installs packages
- ✓How to pin versions sensibly
- ✓How to write a PEP 621 pyproject.toml
- ✓How to build a tiny library locally
- ✓How to publish a test release to TestPyPI
Prerequisites
- •Comfortable in a virtual environment — see Python Virtual Environments
pip is how most Python code gets onto a machine. pyproject.toml is how a project declares what it is and what it needs. Together they are the modern Python packaging story — finally simple after a decade of churn. This post is the practical version: enough to install, distribute, and publish without diving into the historical baggage.
What pip actually does
When you run:
pip install requests
pip does four things:
- Asks PyPI for metadata about
requests - Picks the newest version compatible with your Python and platform
- Recursively resolves every transitive dependency
- Downloads wheels (
.whlfiles) and unpacks them intosite-packages
The result lives in the active environment’s site-packages directory. Inspect what’s there:
pip list # all installed packages
pip show requests # metadata for one package
pip show -f requests # also list files installed
Pinning versions
Three styles you will see in the wild:
requests # latest at install time — risky for apps
requests==2.32.3 # exact pin — reproducible
requests>=2.31,<3 # range — flexible, good for libraries
requests~=2.32 # compatible release — allows 2.32.x only
For applications you deploy, generate a full pin list:
pip freeze > requirements.txt
pip install -r requirements.txt
For libraries you publish, prefer ranges so downstream users can resolve a working set across their own constraints. The split — exact pins for apps, ranges for libraries — is the most useful rule of thumb in Python packaging.
pyproject.toml — the modern manifest
pyproject.toml replaced the older setup.py + setup.cfg pair. It is declarative, parsed before any code runs, and standardised by PEP 621. A minimal file:
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "greetlib"
version = "0.1.0"
description = "Friendly greetings as a library."
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Your Name", email = "you@example.com" }]
license = { text = "MIT" }
dependencies = [
"rich>=13",
]
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.5"]
[project.scripts]
greet = "greetlib.cli:main"
The three blocks worth knowing:
[build-system]— which build backend turns your source into a wheel.hatchling,setuptools,flit-core, andpdm-backendare all valid choices.[project]— the metadata PyPI will display: name, version, dependencies, supported Python versions.[project.scripts]— entry points. Installing this package will create agreetcommand that callsgreetlib.cli:main.
A tiny library, end to end
Lay out the project:
greetlib/
├── pyproject.toml
├── README.md
└── src/
└── greetlib/
├── __init__.py
└── cli.py
src/greetlib/__init__.py:
# greetlib/__init__.py
from rich.console import Console
_console = Console()
def greet(name: str) -> None:
"""Print a friendly greeting using rich."""
_console.print(f"[bold green]Hello, {name}![/]")
src/greetlib/cli.py:
# greetlib/cli.py
import sys
from . import greet
def main() -> None:
name = sys.argv[1] if len(sys.argv) > 1 else "world"
greet(name)
Install your own project into a venv in editable mode — changes to the source take effect immediately, no reinstall:
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
The [dev] suffix pulls in the optional dev dependencies you declared. Now the greet command exists:
greet Alice
# Hello, Alice!
Try it yourself. Add a --upper flag to cli.py that uppercases the name before greeting. Use argparse from the standard library. Reinstall is not needed — editable installs pick up changes instantly. Confirm greet --upper alice prints Hello, ALICE!.
Building distributions
You ship two artefacts to PyPI:
- A wheel (
.whl) — the prebuilt binary form, fast to install - A source distribution (
.tar.gz) — the source, fallback for unusual platforms
Use the build package:
pip install build
python -m build
Both files appear in dist/:
dist/
├── greetlib-0.1.0-py3-none-any.whl
└── greetlib-0.1.0.tar.gz
You can install either directly:
pip install dist/greetlib-0.1.0-py3-none-any.whl
This is how CI systems install your library without going through PyPI at all.
Publishing to TestPyPI
TestPyPI is a sandbox copy of PyPI for trying out releases. Register an account at test.pypi.org, then create an API token from your account settings.
Install twine and upload:
pip install twine
twine upload --repository testpypi dist/*
It will prompt for a username (__token__) and password (the API token, including the pypi- prefix).
Once uploaded, install it into a fresh venv from TestPyPI:
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
greetlib
greet Bob
The --extra-index-url is important — TestPyPI does not mirror real dependencies like rich, so pip needs to fall back to the real PyPI for them.
When you are happy, the production upload is the same command without --repository testpypi. But version numbers on PyPI are immutable — you cannot reupload 0.1.0 after publishing. Bump to 0.1.1 for any change, even a typo fix.
Try it yourself. Bump the version to 0.1.1, run python -m build again, and confirm a new pair of files appears in dist/. Delete the old ones and reupload to TestPyPI. Pip-install the new version into a clean venv and verify the new behaviour.
Version numbers
Most libraries follow semantic versioning: MAJOR.MINOR.PATCH.
PATCHfor backwards-compatible bug fixes (0.1.0→0.1.1)MINORfor new features that don’t break existing code (0.1.0→0.2.0)MAJORfor breaking changes (1.0.0→2.0.0)
While you are below 1.0.0, the rules are looser — anything can break. Cross 1.0.0 only when you are willing to commit to compatibility.
Reading what got installed
A few pip commands that earn their keep:
pip check # warn about broken dependency trees
pip list --outdated # which packages have newer versions on PyPI
pip install --upgrade requests
pip uninstall requests
pip download requests -d ./wheels # fetch wheels without installing
pip check is the underused one. After a big upgrade, it catches “package A needs B>=2.0 but B==1.5 is installed” before your code crashes at runtime.
Choosing a build backend
hatchling is a sensible default — it is fast, modern, and has no quirks. Two others you will see:
setuptools— the historic default. Powerful but loaded with legacy behaviour. Pick it only if you have C extensions or unusual needs.flit-core— the simplest possible backend for pure-Python libraries with no build steps.
The choice is internal — your users never see it. They just pip install your wheel.
Lockfiles and the future
pyproject.toml declares ranges. A lockfile records the exact versions that were resolved on a given day, including every transitive dependency. Tools like uv, poetry, and pdm generate lockfiles and check them into git. Two developers running uv sync get byte-identical environments.
If you adopt uv (covered in Python Virtual Environments), it manages the venv, the pyproject, and the uv.lock file together. The packaging story is finally as smooth as Node’s or Rust’s.
Recap
You now know:
pip installresolves transitive dependencies and unpacks wheels intosite-packages- Apps pin exact versions; libraries declare ranges
pyproject.tomlis the modern, declarative manifest (PEP 621)- A minimal project needs
[build-system],[project], and a source tree undersrc/ python -m buildproduces a wheel and an sdist indist/- TestPyPI is a safe sandbox for practising real releases
- Version numbers on PyPI are immutable — bump for every upload
Next steps
Python’s packaging chapter ends here for the moment — you can install, declare, build, and publish. The next post crosses over to JavaScript, where the module story is younger but cleaner: import and export between files in modern ES Modules.
→ Next: JavaScript ES Modules: import and export
Questions or feedback? Email codeloomdevv@gmail.com.