Skip to content
C Codeloom
Python

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.

·7 min read · By Yash Kesharwani
Intermediate 11 min read

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

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:

  1. Asks PyPI for metadata about requests
  2. Picks the newest version compatible with your Python and platform
  3. Recursively resolves every transitive dependency
  4. Downloads wheels (.whl files) and unpacks them into site-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, and pdm-backend are all valid choices.
  • [project] — the metadata PyPI will display: name, version, dependencies, supported Python versions.
  • [project.scripts] — entry points. Installing this package will create a greet command that calls greetlib.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.

  • PATCH for backwards-compatible bug fixes (0.1.00.1.1)
  • MINOR for new features that don’t break existing code (0.1.00.2.0)
  • MAJOR for breaking changes (1.0.02.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 install resolves transitive dependencies and unpacks wheels into site-packages
  • Apps pin exact versions; libraries declare ranges
  • pyproject.toml is the modern, declarative manifest (PEP 621)
  • A minimal project needs [build-system], [project], and a source tree under src/
  • python -m build produces a wheel and an sdist in dist/
  • 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.