Packaging: Turn Your Script Into an Installable Tool
From python thing.py to just typing its name
In this lesson
You have a script that works. To use it you cd to the right folder and type python tidyup.py, and if you send it to a colleague they have to be told which packages to install first.
Packaging ends both problems. It turns the script into something you install once and then run by name from anywhere, and it is the final topic on the Advanced roadmap.
Explain it like I’m 5
Packaging is putting your script in a box with a label saying what it needs, so any computer can unpack it and know how to run it.
Reproducing an environment vs shipping an artifact
Unit 5 covered virtual environments and requirements.txt. It is worth being precise about how that differs from what happens here, because the two get confused constantly.
requirements.txt reproduces an environment. It is a list of what to install so this project runs on another machine. It says nothing about your code: your code is just the files sitting there.
pyproject.toml ships an artifact. It describes your project as an installable thing: its name, its version, what it needs, and what commands it provides. After it exists, pip install works on your code the same way it works on requests.
One is for the people who develop the project. The other is for the people who merely want to use it.
Where the code goes: the src layout
Before the config, the folders. A packaged project puts the importable code inside a src/ directory:
The reason is subtle and worth knowing. Without src/, your project folder is on the path whenever you run Python there, so import tidyup finds the local files whether or not the package installed correctly. Your tests then pass against code that would fail for everyone else. Moving the code into src/ forces the tests to use the installed copy — so what you test is what you ship.
tidyup/
├── pyproject.toml # the label on the box
├── README.md # what it does, how to use it
├── src/
│ └── tidyup/ # the importable package
│ ├── __init__.py # marks it as a package
│ ├── cli.py # the command-line entry point
│ └── sorter.py # the actual work
└── tests/
└── test_sorter.py
pyproject.toml, line by line
This one file replaces the setup.py, setup.cfg and MANIFEST.in you will see in older projects. It is TOML, a config format designed to be readable: sections in square brackets, key = value underneath.
There are only three parts to understand: which tool builds the package, what the package is, and what commands it provides.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "tidyup"
version = "0.1.0"
description = "Sort a messy downloads folder into dated subfolders."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"rich>=13.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff"]
[project.scripts]
tidyup = "tidyup.cli:main"
Entry points: the line that makes it a command
tidyup = "tidyup.cli:main" reads as: create a command called tidyup that imports the module tidyup.cli and calls the function main. The colon separates the module from the function inside it.
On install, pip writes a tiny launcher script into the environment's bin/ folder. Because that folder is already on your PATH, the command works from any directory — which is the entire difference between a script and a tool.
The function it points at takes no arguments and returns nothing. It reads sys.argv itself, usually via argparse from Unit 5.
# src/tidyup/cli.py - the function the entry point names.
import argparse
def main():
"""The tidyup command. Reads arguments, does the work, prints a summary."""
parser = argparse.ArgumentParser(prog="tidyup",
description="Sort a folder into dated subfolders.")
parser.add_argument("folder", help="the folder to tidy")
parser.add_argument("--dry-run", action="store_true",
help="show what would happen, change nothing")
args = parser.parse_args(["~/Downloads", "--dry-run"]) # normally: parse_args()
print(f"folder: {args.folder}")
print(f"dry run: {args.dry_run}")
if __name__ == "__main__":
main()
folder: ~/Downloads dry run: True
Read a pyproject.toml the way pip does. tomllib is in the standard library, so parsing TOML needs no install. Pull out the name, version, dependency count and command, then split the entry point "tidyup.cli:main" into the module and the function it names.
import tomllib
PYPROJECT = """
[project]
name = "tidyup"
version = "0.3.1"
dependencies = ["rich>=13.0", "click"]
[project.scripts]
tidyup = "tidyup.cli:main"
"""
data = tomllib.loads(PYPROJECT)
name = "" # TODO
version = "" # TODO
deps = 0 # TODO: how many dependencies
command = "" # TODO: the name of the command it installs
target = "" # TODO: what that command points at
module, function = "", "" # TODO: split target on the colon
print(f"{name} {version}")
print(f"dependencies: {deps}")
print(f"command: {command} -> import {module}, call {function}()")
tomllib.loads() gives you nested dictionaries, so data["project"]["name"] reads the name. [project.scripts] becomes data["project"]["scripts"], whose first key is the command name and whose value is the target. Split that on ":".
import tomllib
PYPROJECT = """
[project]
name = "tidyup"
version = "0.3.1"
dependencies = ["rich>=13.0", "click"]
[project.scripts]
tidyup = "tidyup.cli:main"
"""
data = tomllib.loads(PYPROJECT)
name = data["project"]["name"]
version = data["project"]["version"]
deps = len(data["project"]["dependencies"])
command = list(data["project"]["scripts"])[0]
target = data["project"]["scripts"][command]
module, function = target.split(":")
print(f"{name} {version}")
print(f"dependencies: {deps}")
print(f"command: {command} -> import {module}, call {function}()")
tidyup 0.3.1
dependencies: 2
command: tidyup -> import tidyup.cli, call main()
Installing your own project, and building the box
With the file in place, install it into your virtual environment in editable mode:
pip install -e . — the . means “the project in this folder” and -e means editable: pip links to your source rather than copying it, so edits take effect immediately with no reinstall. This is how you work on a package day to day.
To give it to someone else, build a wheel: a .whl file, which is a zip of your package plus its metadata. It is the format pip installs, and building one takes a single command.
$ pip install -e .
Obtaining file:///home/ada/tidyup
Installing build dependencies ... done
Successfully installed tidyup-0.1.0
$ tidyup ~/Downloads --dry-run # the command now exists, from anywhere
folder: /home/ada/Downloads
dry run: True
$ pip install build
$ python -m build
Successfully built tidyup-0.1.0.tar.gz and tidyup-0.1.0-py3-none-any.whl
$ ls dist/
tidyup-0.1.0-py3-none-any.whl tidyup-0.1.0.tar.gz
PyPI, and why you probably do not need it
PyPI is the public index pip install reads from. Publishing there means pip install twine, an account, an API token, and twine upload dist/*. It is genuinely that short.
The honest advice is that most projects should not. Publishing makes you responsible for a name forever, for anyone who comes to depend on it, and for the security of the account that can push updates. Meanwhile the alternatives cover nearly every real need:
- Hand over the wheel —
pip install ./tidyup-0.1.0-py3-none-any.whl. Works offline, no account. - Install from Git —
pip install git+https://github.com/you/tidyup. Works with private repositories too. - Install from a folder —
pip install .on a shared drive.
Publish when strangers should be able to find and install it by name. Until then, packaging has already given you everything useful.
Common mistake: Forgetting to raise the version number
The code changed, so it feels like the package changed.
Pip skips a reinstall when the version matches what is already there, so your fix silently does not arrive. Raise version in pyproject.toml with every release.
Common mistake: A dependency that works only because it is installed globally
You installed it once months ago, so your imports work and it never occurs to you to list it.
Create a fresh virtual environment, install only your package, and run it. Every ModuleNotFoundError is a line missing from dependencies.
Common mistake: Choosing a name already taken on PyPI
The obvious name for a tool is obvious to everyone.
Search PyPI before naming the project. Changing it later means changing the folder, the imports, the entry point and every document.
Common mistake: Expecting requirements.txt to make the package installable
It lists the dependencies, so it looks like it is doing the same job.
It only reproduces an environment. Without pyproject.toml there is no package, no version, and no command: pip has nothing to install.
Common mistake: Putting the package at the top level instead of in src/
It is one less folder and everything appears to work.
It works because the current directory is on the path, so your tests never exercise the installed copy. A missing file then only shows up for your users. The src/ layout makes that impossible.
What does [project.scripts] do?
name = "module:function" installs a launcher on your PATH, so the tool runs from any directory.
Why install your own project with pip install -e . ?
Editable mode is the development workflow. A plain pip install . copies the files, so every change needs reinstalling.
What is the difference between requirements.txt and pyproject.toml?
requirements.txt tells a developer what to install. pyproject.toml makes your own code something pip can install.
What is a wheel?
A .whl is a zip of your package plus its metadata, ready to install with nothing to build.
Mini exercise (hard)
Write the check that saves a release. problems(data) takes a parsed pyproject.toml and returns a sorted list of what is wrong with it: a missing name, a missing version, an entry point with no colon in it, and an entry point whose module does not start with the package name. Return an empty list when the file is fine.
Try it yourself. Complete the snippet and hit Run; it executes in your browser, no setup required.
import tomllib
GOOD = """
[project]
name = "tidyup"
version = "0.3.1"
[project.scripts]
tidyup = "tidyup.cli:main"
"""
BROKEN = """
[project]
name = "tidyup"
[project.scripts]
tidyup = "tidyup.cli.main"
sortit = "helpers.tools:run"
"""
def problems(data):
"""Everything wrong with a parsed pyproject.toml, sorted."""
found = []
# TODO: "missing name" / "missing version"
# TODO: for each entry point, no colon -> f"{command}: entry point needs module:function"
# TODO: module outside the package -> f"{command}: {module} is not inside {package}"
return sorted(found)
print("good: ", problems(tomllib.loads(GOOD)) or "ok")
for problem in problems(tomllib.loads(BROKEN)):
print("broken:", problem)
print("empty: ", problems({}))
Start from data.get("project", {}) so a file with no [project] section does not raise. Collect messages into a list, then sorted() it at the end. For each entry in scripts, check ":" in target first, because splitting a target without one will not give you two pieces.
import tomllib
GOOD = """
[project]
name = "tidyup"
version = "0.3.1"
[project.scripts]
tidyup = "tidyup.cli:main"
"""
BROKEN = """
[project]
name = "tidyup"
[project.scripts]
tidyup = "tidyup.cli.main"
sortit = "helpers.tools:run"
"""
def problems(data):
"""Everything wrong with a parsed pyproject.toml, sorted."""
project = data.get("project", {})
package = project.get("name", "")
found = []
if not package:
found.append("missing name")
if not project.get("version"):
found.append("missing version")
for command, target in project.get("scripts", {}).items():
if ":" not in target:
found.append(f"{command}: entry point needs module:function")
continue
module = target.split(":", 1)[0]
if module.split(".")[0] != package:
found.append(f"{command}: {module} is not inside {package}")
return sorted(found)
print("good: ", problems(tomllib.loads(GOOD)) or "ok")
for problem in problems(tomllib.loads(BROKEN)):
print("broken:", problem)
print("empty: ", problems({}))
good: ok
broken: missing version
broken: sortit: helpers.tools is not inside tidyup
broken: tidyup: entry point needs module:function
empty: ['missing name', 'missing version']
assert problems({}) == ["missing name", "missing version"], "an empty file is missing both"
assert problems({"project": {"name": "a", "version": "1"}}) == [], "name and version with no scripts is fine"
assert problems({"project": {"name": "a", "version": "1", "scripts": {"a": "a.cli:main"}}}) == [], "a valid entry point inside the package is fine"
assert problems({"project": {"name": "a", "version": "1", "scripts": {"a": "a.cli.main"}}}) == ["a: entry point needs module:function"], "a dot is not a colon"
assert problems({"project": {"name": "a", "version": "1", "scripts": {"a": "other.cli:main"}}}) == ["a: other.cli is not inside a"], "the module must live inside the package that ships"
print("✓ Looks good!")