Packaging with pyproject.toml
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.
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))₹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 youimport. A distribution package is the installable file you upload; its name is what youpip install. They often differ: you installrupee-formatbut importrupee_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 andpyproject.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,piporuv, calls the backend for you.
The project layout
The recommended structure is the src layout: your import package lives inside a src folder.
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.pyWhy 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).
[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:
namemust be unique on PyPI, andversionmust change for every upload.requires-pythonstops pip installing your package on Python versions you do not support.licenseis an SPDX licence identifier such as"MIT"or"Apache-2.0", andlicense-fileslists the licence text files to include.dependencieslists 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 typesinr, call themain()function inrupee_format/cli.py, themain(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:
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}")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:
| Backend | requires | build-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:
python -m pip install build
python -m build* 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:
python -m pip install dist/rupee_format-0.1.0-py3-none-any.whl
inr 1234567.5 499₹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:
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:
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.
uv init --package marks-report
cd marks-report
uv build
uv publish --publish-url https://test.pypi.org/legacy/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, butimport 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.3forces every user onto one release; use a minimum such asrequests>=2.32. - Committing
dist/to Git. Build files are created from the source; adddist/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 buildcreates a wheel and an sdist indist/;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 buildanduv publishdo the same jobs quickly;setup.pycommands 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
[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"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?
Wheels are built distributions; installing one is mostly copying files.
2. Which table in pyproject.toml says which tool builds the package?
[build-system] lists the backend's requirements and its build-backend entry point.
3. What does inr = "rupee_format.cli:main" under [project.scripts] do?
Script entries become commands on the user's PATH when the package is installed.
4. Why does an upload to PyPI fail with "File already exists"?
PyPI never accepts the same version twice, even after deletion.
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
