Packaging with pyproject.toml

Python · Lesson 53 of 56 9 min read Updated

Once you have written a useful module, you will want to share it: with a friend, with your team, or with the whole world through PyPI, the Python Package Index that pip downloads from. Copying .py files around does not scale. Instead, you package the code: describe it in a pyproject.toml file, build it into a standard file that pip can install, and upload that file.

In this lesson you will package a small library called rupee-format, which shows numbers as rupees with Indian digit grouping. You will set up the folder layout, write pyproject.toml, choose a build backend, build a wheel, install it, add a command-line command, and publish to TestPyPI, the practice copy of PyPI. You should already know pip & Virtual Environments and Modules & Imports.

The code we will package

Here is the library itself. It is ordinary Python; packaging does not change your code at all.

PythonRun it yourself
def format_inr(amount: float) -> str:
    """Return amount as rupees with Indian digit grouping, e.g. ₹12,34,567.50."""
    sign = "-" if amount < 0 else ""
    whole, paise = f"{abs(amount):.2f}".split(".")
    last3, rest = whole[-3:], whole[:-3]
    groups = []
    while rest:
        groups.insert(0, rest[-2:])        # lakh and crore use groups of two
        rest = rest[:-2]
    grouped = ",".join(groups + [last3]) if groups else last3
    return f"{sign}₹{grouped}.{paise}"

for amount in (499, 75000, 1234567.5, 125000000, -2500):
    print(format_inr(amount))
Output
₹499.00
₹75,000.00
₹12,34,567.50
₹12,50,00,000.00
-₹2,500.00

Words you need

  • An import package is a folder of modules with an __init__.py, which you import. A distribution package is the installable file you upload; its name is what you pip install. They often differ: you install rupee-format but import rupee_format.
  • A wheel (.whl) is a ready-to-install archive. Installing it just copies files, so it is fast.
  • A source distribution, or sdist (.tar.gz), contains your source and pyproject.toml, so it can be rebuilt anywhere.
  • A build backend is the tool that turns your project into a wheel and sdist. A build frontend, such as python -m build, pip or uv, calls the backend for you.

The project layout

The recommended structure is the src layout: your import package lives inside a src folder.

Text
rupee-format/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── rupee_format/
│       ├── __init__.py      # format_inr() lives here
│       └── cli.py           # the command-line tool
└── tests/
    └── test_format.py

Why src? Without it, Python can import your package straight from the project folder, so your tests may pass even when the installed package is broken, for example because a file was left out. With the src layout, tests only work after a real install, which catches packaging mistakes early.

Writing pyproject.toml

pyproject.toml is written in TOML, a simple settings format with [sections] and key = value lines. The [build-system] table names the backend; the [project] table holds the metadata (the information PyPI shows about your package).

Text
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"

[project]
name = "rupee-format"
version = "0.1.0"
description = "Format amounts in Indian rupees with lakh and crore grouping."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Priya Sharma", email = "priya@example.com" }]
dependencies = []

[project.scripts]
inr = "rupee_format.cli:main"

[project.urls]
Homepage = "https://github.com/priya-sharma/rupee-format"

The important fields:

  • name must be unique on PyPI, and version must change for every upload.
  • requires-python stops pip installing your package on Python versions you do not support.
  • license is an SPDX licence identifier such as "MIT" or "Apache-2.0", and license-files lists the licence text files to include.
  • dependencies lists other PyPI packages your code needs, for example ["requests>=2.32"]. pip installs them automatically.
  • [project.scripts] creates a terminal command. inr = "rupee_format.cli:main" means: when someone types inr, call the main() function in rupee_format/cli.py, the main(argv=None) pattern from Command-line Apps with argparse.

Python can read TOML itself with the standard tomllib module (Python 3.11 and later), which is handy for checking a file or reading your own settings:

PythonRun it yourself
import tomllib

text = '''
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"

[project]
name = "rupee-format"
version = "0.1.0"
dependencies = ["requests>=2.32"]

[project.scripts]
inr = "rupee_format.cli:main"
'''

config = tomllib.loads(text)
project = config["project"]
print(project["name"], project["version"])
print("Backend:", config["build-system"]["build-backend"])
print("Needs:", project.get("dependencies", []))
for command, target in project.get("scripts", {}).items():
    print(f"Command '{command}' runs {target}")
Output
rupee-format 0.1.0
Backend: hatchling.build
Needs: ['requests>=2.32']
Command 'inr' runs rupee_format.cli:main

Choosing a build backend

All modern backends read the same [project] table, so switching is mostly a matter of changing [build-system]. The Python Packaging User Guide lists these common choices:

Backendrequiresbuild-backend
Hatchling"hatchling >= 1.26""hatchling.build"
setuptools"setuptools >= 77.0.3""setuptools.build_meta"
Flit"flit_core >= 3.12.0, <5""flit_core.buildapi"
PDM"pdm-backend >= 2.4.0""pdm.backend"
uv"uv_build >= 0.13.5, <0.14.0""uv_build"

For a pure-Python library, any of them is fine. setuptools is the oldest and still needed for packages with C extensions; Hatchling and uv_build are popular for new projects.

Building and installing locally

Install the build frontend once, then build from the folder that contains pyproject.toml:

Terminal
python -m pip install build
python -m build
Output
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling >= 1.26
* Getting build dependencies for wheel...
* Installed build dependency versions:
  - hatchling==1.33.0
* Building wheel...
Successfully built rupee_format-0.1.0.tar.gz and rupee_format-0.1.0-py3-none-any.whl

The dist/ folder now holds the sdist and the wheel. The wheel's name records the package, version and compatibility: py3-none-any means "any Python 3, no compiled code, any operating system". Test it in a fresh virtual environment, exactly as a user would:

Terminal
python -m pip install dist/rupee_format-0.1.0-py3-none-any.whl
inr 1234567.5 499
Output
₹12,34,567.50
₹499.00

While developing, install in editable mode with python -m pip install -e . instead. pip then points at your src folder, so code changes take effect without rebuilding.

Publishing to TestPyPI

TestPyPI (test.pypi.org) is a separate practice index, so you can learn the upload steps without cluttering the real PyPI. Create an account there, then create an API token in your account settings: a long secret starting with pypi- that you use instead of a password. Upload with twine:

Terminal
python -m pip install twine
python -m twine check dist/*
python -m twine upload --repository testpypi dist/*

twine check makes sure your README will display correctly. The upload asks for your token. Then try installing your package from TestPyPI in a new virtual environment:

Terminal
python -m pip install --index-url https://test.pypi.org/simple/ --no-deps rupee-format

--no-deps is used because TestPyPI does not have copies of most real packages. When everything works, upload to the real PyPI with python -m twine upload dist/* and a token from pypi.org.

The fast way: uv

uv can create, build and publish packages too. uv init --package creates a src-layout project with a pyproject.toml that already uses the uv_build backend and a [project.scripts] entry. uv build builds the sdist and wheel, and uv publish uploads them.

Terminal
uv init --package marks-report
cd marks-report
uv build
uv publish --publish-url https://test.pypi.org/legacy/
Output
Successfully built dist\marks_report-0.1.0.tar.gz
Successfully built dist\marks_report-0.1.0-py3-none-any.whl

The output shows the two files built by uv build. uv publish then needs your API token, which you can pass with --token or the UV_PUBLISH_TOKEN environment variable. Many projects on GitHub publish automatically with trusted publishing, where PyPI trusts the GitHub workflow and no token is stored at all.

Common mistakes

  • Forgetting to bump the version. The upload fails with "File already exists".
  • Mixing up names. pip install rupee-format, but import rupee_format. Dashes are not allowed in import names.
  • Testing only from the project folder. Install the built wheel in a fresh environment to catch missing files.
  • Listing pinned versions in dependencies. requests==2.32.3 forces every user onto one release; use a minimum such as requests>=2.32.
  • Committing dist/ to Git. Build files are created from the source; add dist/ to .gitignore.

Key points

  • A package is described by pyproject.toml: [build-system] names the backend, [project] holds the metadata.
  • Use the src layout so tests run against the installed package.
  • [project.scripts] turns a function into a terminal command.
  • python -m build creates a wheel and an sdist in dist/; pip install -e . installs for development.
  • Upload to TestPyPI with twine upload --repository testpypi dist/* before the real PyPI, and bump the version every time.
  • uv init --package, uv build and uv publish do the same jobs quickly; setup.py commands are legacy.

Exercise

Write a complete pyproject.toml for a package called chai-timer (import name chai_timer) at version 0.2.0, using the setuptools backend. It needs Python 3.12 or newer, depends on rich version 13 or later, uses the MIT licence, and provides a terminal command chai that calls main() in chai_timer/app.py. Then write the commands to build it and upload it to TestPyPI.

Show solution
Text
[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "chai-timer"
version = "0.2.0"
description = "A tiny timer that tells you when your chai is ready."
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
dependencies = ["rich>=13"]

[project.scripts]
chai = "chai_timer.app:main"
Terminal
python -m pip install build twine
python -m build
python -m twine upload --repository testpypi dist/*

Only the [build-system] table is specific to setuptools; the [project] table would work unchanged with any other backend. The script entry uses the import name with an underscore, a dot path to the module, and a colon before the function name.

Quick Quiz

1. What is a wheel?

2. Which table in pyproject.toml says which tool builds the package?

3. What does inr = "rupee_format.cli:main" under [project.scripts] do?

4. Why does an upload to PyPI fail with "File already exists"?

Learn Python with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call