Packaging a Python Project With pyproject.toml
Turn a folder of code into an installable package with pyproject.toml, a src layout and a command-line entry point. After this, other people can install your code with pip instead of copying files.
Course content
Sign in to save your progress.
-
Functions That Wrap Functions
-
Python Closures: Functions That Remember
-
Python Decorators in Real Code: Timing, Retry and Logging
-
-
-
Data That Doesn't Fit in Memory
-
Python Iterators: What a for Loop Actually Does
-
Python Generators and yield: One Piece at a Time
-
Python Context Managers: Clean Up Even When Things Fail
-
-
-
Catch Bugs Before You Run the Code
-
Python Type Hints Beyond the Basics: Optional, Union, Generics
-
Checking Types With mypy in a Real Project
-
-
-
Doing More Than One Thing at a Time
-
Python Concurrency Basics: Threads, Processes or asyncio?
-
Python asyncio: Wait for Many Things Without Waiting Longer
-
-
-
Ship It, Then Make It Fast
-
Packaging a Python Project With pyproject.toml
-
Profiling Python Code: Measure Before You Optimize
-
-
- Final Check: Python Advanced
If people get your code by copying a folder, it isn't packaged yet. A package is something they install with pip install, that brings its own dependencies, and that gives them a command to run. Since a few years ago, all of that lives in one file: pyproject.toml.
Skip ahead: If you already build packages with a src layout, entry points and pip install -e ., and know why libraries shouldn't pin exact versions, continue to "Profiling Python Code: Measure Before You Optimize."
Do you need this?
Not for a script that lives in one folder and runs on one server. You need it when other people or other projects use your code, when you want a proper command instead of python some/path/script.py, or when the "how to set this up" instructions have grown past a few lines. Publishing to PyPI, the public package index, is optional. A package is useful even if it is only installed from a Git repository or a local folder.
The layout
pricetool/
├── pyproject.toml
├── README.md
├── src/
│ └── pricetool/
│ ├── __init__.py
│ ├── pricing.py
│ └── cli.py
└── tests/
└── test_pricing.py
The code lives in src/pricetool/, not directly in the project folder. This is called the src layout, and the reason is practical: when your tests run, they have to import the installed package, not the folder you happen to be standing in. That catches packaging mistakes, like a missing file, before your users do.
The code
# src/pricetool/__init__.py
__version__ = "0.1.0"
# src/pricetool/pricing.py
def add_vat(amount: float, rate: float = 0.12) -> float:
return round(amount * (1 + rate), 2)
# src/pricetool/cli.py
import argparse
from pricetool import __version__
from pricetool.pricing import add_vat
def main() -> None:
parser = argparse.ArgumentParser(prog="pricetool", description="Add VAT to an amount.")
parser.add_argument("amount", type=float)
parser.add_argument("--rate", type=float, default=0.12)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
args = parser.parse_args()
print(add_vat(args.amount, args.rate))
The 12% default is an example rate for the exercise.
pyproject.toml
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "pricetool"
version = "0.1.0"
description = "Add VAT to amounts from the command line."
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
pricetool = "pricetool.cli:main"
echo "# pricetool" > pricetool/README.md
Section by section:
[build-system]says which tool builds the package. setuptools is the long-standing default. Hatchling, Flit and others work too; the[project]section stays the same.[project]is the metadata: name, version, Python versions supported, anddependencies, the packages pip installs along with yours.[project.scripts]creates a command.pricetool = "pricetool.cli:main"means: when someone typespricetool, callmain()frompricetool/cli.py. On Windows, pip creates apricetool.exefor it.
Install it while you work on it
python3 -m venv .venv
.venv/bin/python -m pip install --quiet -e ./pricetool
.venv/bin/pricetool 1000
.venv/bin/pricetool 1000 --rate 0.09
.venv/bin/pricetool --version
1120.0
1090.0
pricetool 0.1.0
-e means editable: the environment points at your source folder, so changes to the code take effect without reinstalling. With an activated environment, you would just type pricetool 1000; the .venv/bin/ prefix is only there because each line here runs on its own.
Build the files you share
.venv/bin/python -m pip install --quiet build
cd pricetool && ../.venv/bin/python -m build 2>&1 | tail -n 1 && ls dist
Successfully built pricetool-0.1.0.tar.gz and pricetool-0.1.0-py3-none-any.whl
pricetool-0.1.0-py3-none-any.whl
pricetool-0.1.0.tar.gz
The .whl (wheel) is the ready-to-install file. The .tar.gz is the source. Anyone can install the wheel with pip install pricetool-0.1.0-py3-none-any.whl, or you can install straight from Git with pip install git+https://.... Publishing to PyPI uses a tool called twine and an account there; check the current PyPI documentation before doing it for the first time.
⚠ Warning: Before building, make sure no secrets or private data are inside src/: .env files, API keys, customer exports used for testing. Whatever is in the package folder can end up in the wheel, and a package uploaded to PyPI is public. Open the wheel (it is a zip file) and look inside before you publish it anywhere.
Dependencies: ranges, not pins
In the Python Intermediate course, applications pinned exact versions in requirements.txt. A package is different. If your package says requests==2.32.3, and another package in the same environment needs a slightly newer version, pip can't install both, and the user is stuck. So packages give ranges:
dependencies = [
"requests>=2.31,<3",
]
"At least the version I tested with, below the next major version." The application that installs your package pins the final exact versions in its own lock or requirements file. Both rules come from the same idea: the project that gets deployed decides the exact versions.
Versions
Change the version every time you share a new build. Most projects use MAJOR.MINOR.PATCH: patch for bug fixes, minor for new features, major for changes that can break the people using it. Two different builds with the same version number is how people end up debugging code that isn't the code they think it is.
Common mistakes
- Forgetting
__init__.pyor putting code outsidesrc/pricetool/. It works from your folder and is missing from the installed package. - Keeping a
setup.pyand apyproject.tomlwith different metadata. For new projects, usepyproject.tomlonly. - Reading data files with paths relative to the current folder. Once installed, the package isn't in the current folder. Use
importlib.resourcesfor files that ship with the package.
💡 Tip: Test the wheel the way a user would: create a fresh virtual environment somewhere else, install the .whl, run the command. If it fails there, it would have failed for everyone else.
What happens if you skip this step
Sharing the code means sending a folder and a page of instructions. Each person sets it up slightly differently, with different dependency versions, and "it doesn't work for me" turns into an afternoon of comparing setups. A package turns all of that into one install command that either works or tells you exactly what is missing.
If a coworker had to use your most useful script tomorrow, how many steps would your setup instructions have?
About
Turn a folder of code into an installable package with pyproject.toml, a src layout and a command-line entry point. After this, other people can install your code with pip instead of copying files.
- Duration
- 10 min
- Last update
- 09/02/2026
Comments (0)
Sign in to commentNo comments yet.