Package and Ship a Command-Line Tool
Take a script that only runs in its own folder and turn it into a real tool: the src layout, a pyproject.toml, an entry point that makes it a terminal command, tests, a CI workflow, and a wheel you can hand to someone.
About this project
You have a script that works. To use it you cd to the right folder and type python csvpeek.py, and to give it to a colleague you send the file and a paragraph of instructions about which packages to install first.
Why it is worth building: this is the last step of the Advanced roadmap, and it is the one that changes how your work feels. Afterwards the tool installs with pip, runs by name from any directory, declares its own dependencies, and has a test suite a robot runs on every push. The script does not get better; everything around it does.
Packaging is mostly file moves and one config file, so most blocks here are shown rather than run, but the parts that are real logic still run on this page.
Build it step by step
We will package a small real tool: csvpeek, which prints a quick summary of any CSV. It starts as one file and ends as something you can install.
Step 0: The script we are starting from
Here is the tool as most people write it first: one file, everything in it, run from the folder it lives in. There is nothing wrong with the code; the problem is entirely in how it is delivered.
"""Print a quick summary of a CSV file."""
import csv
import sys
def summarize(rows):
"""Return (row count, column names) for a list of dict rows."""
if not rows:
return 0, []
return len(rows), list(rows[0])
def main():
with open(sys.argv[1], newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
count, columns = summarize(rows)
print(f"{count} rows, {len(columns)} columns")
for column in columns:
print(" -", column)
if __name__ == "__main__":
main()
Step 1: Move the code into a src layout
The first change is where the file lives. The package goes inside src/, and the reason is specific rather than aesthetic.
Without src/, your project folder is on the path whenever you run Python there, so import csvpeek finds the local files whether or not the package actually installed correctly. Your tests then pass against code that would fail for everyone else. Moving it into src/ makes that impossible: the tests can only reach the installed copy.
csvpeek/
├── pyproject.toml # the label on the box
├── README.md
├── src/
│ └── csvpeek/
│ ├── __init__.py # marks it a package; can be empty
│ ├── cli.py # argument handling and printing
│ └── summary.py # the pure logic worth testing
└── tests/
└── test_summary.py
Step 2: Describe the project
One file replaces the setup.py and setup.cfg you will see in older projects. Three sections: what builds it, what it is, and what commands it provides.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "csvpeek"
version = "0.1.0"
description = "Print a quick summary of a CSV file."
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff"]
[project.scripts]
csvpeek = "csvpeek.cli:main"
Step 3: The entry point, and what it really does
csvpeek = "csvpeek.cli:main" reads as: make a command called csvpeek that imports csvpeek.cli and calls main. The colon separates the module from the function.
It looks like magic and is not. Resolving module:function is an import followed by getattr, which you can watch happen right here, using a standard-library module as the target.
import importlib
TARGET = "csv:list_dialects" # exactly the module:function form
module_name, function_name = TARGET.split(":")
module = importlib.import_module(module_name)
command = getattr(module, function_name)
print("module: ", module_name)
print("function:", function_name)
print("calling: ", sorted(command()))
module: csv function: list_dialects calling: ['excel', 'excel-tab', 'unix']
Step 4: Test the logic, not the printing
CI is only worth having if there is something to run, and the thing to test is the pure function. summarize takes rows and returns an answer: no files, no arguments, no output to capture.
Test the edges, as always: the empty case first, because it is the one most likely to be wrong.
def summarize(rows):
"""Return (row count, column names) for a list of dict rows."""
if not rows:
return 0, []
return len(rows), list(rows[0])
# tests/test_summary.py: plain asserts here; pytest runs the same statements.
def test_empty_file_has_no_rows_or_columns():
assert summarize([]) == (0, [])
def test_counts_rows_and_reads_columns_from_the_first():
rows = [{"name": "ada", "role": "eng"}, {"name": "sam", "role": "ops"}]
assert summarize(rows) == (2, ["name", "role"])
def test_single_row_still_reports_its_columns():
assert summarize([{"only": "one"}]) == (1, ["only"])
for test in [test_empty_file_has_no_rows_or_columns,
test_counts_rows_and_reads_columns_from_the_first,
test_single_row_still_reports_its_columns]:
test()
print("passed:", test.__name__)
passed: test_empty_file_has_no_rows_or_columns passed: test_counts_rows_and_reads_columns_from_the_first passed: test_single_row_still_reports_its_columns
Step 5: Install it, and use it like a command
With the config in place, install the project into your virtual environment in editable mode. -e links to your source instead of copying it, so edits take effect with no reinstall.
$ python -m venv .venv && source .venv/bin/activate
$ pip install -e ".[dev]"
Obtaining file:///home/ada/csvpeek
Installing build dependencies ... done
Successfully installed csvpeek-0.1.0 pytest-8.1.1 ruff-0.5.0
$ cd /tmp # anywhere at all
$ csvpeek ~/data/sales.csv
12 rows, 6 columns
- order_id
- date
- region
- product
- units
- unit_price
$ pytest -q
... [100%]
3 passed in 0.01s
Step 6: Let a robot check every push
Now that tests exist, hand them to GitHub Actions. The workflow installs your package from its own pyproject.toml, so if a dependency is missing from that file, this is the step that catches it.
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: ruff check .
- run: pytest -q
Step 7: Build the thing you hand over
Finally, build a wheel: a .whl file, which is a zip of your package plus its metadata, and the format pip installs.
$ pip install build
$ python -m build
Successfully built csvpeek-0.1.0.tar.gz and csvpeek-0.1.0-py3-none-any.whl
$ ls dist/
csvpeek-0.1.0-py3-none-any.whl csvpeek-0.1.0.tar.gz
$ pip install dist/csvpeek-0.1.0-py3-none-any.whl # on any machine, offline
Successfully installed csvpeek-0.1.0
The finished project
The whole package, and the two files that carry all the new information.
csvpeek/
├── pyproject.toml
├── README.md
├── .github/workflows/tests.yml
├── src/csvpeek/
│ ├── __init__.py
│ ├── cli.py # def main(): reads sys.argv, prints the summary
│ └── summary.py # def summarize(rows): the tested logic
└── tests/test_summary.py
# src/csvpeek/cli.py
import csv
import sys
from csvpeek.summary import summarize
def main():
"""The csvpeek command."""
if len(sys.argv) != 2:
print("Usage: csvpeek <file.csv>")
raise SystemExit(1)
with open(sys.argv[1], newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
count, columns = summarize(rows)
print(f"{count} rows, {len(columns)} columns")
for column in columns:
print(" -", column)
if __name__ == "__main__":
main()
Keep going, make it your own
The tool ships. These are the next things a real package grows.
Give it proper arguments
Swap the hand-rolled sys.argv check for argparse. You get --help, type conversion, and clear errors for free, and --help is the first thing anyone tries.
import argparse
parser = argparse.ArgumentParser(prog="csvpeek",
description="Print a quick summary of a CSV file.")
parser.add_argument("path", help="the CSV file to inspect")
parser.add_argument("--rows", type=int, default=0, help="also show the first N rows")
args = parser.parse_args()
Add a --version flag that cannot drift
Read the version from the installed metadata rather than typing it a second time. One source of truth means the flag can never disagree with pyproject.toml.
from importlib.metadata import version
parser.add_argument("--version", action="version",
version=f"%(prog)s {version('csvpeek')}")
Have CI build the wheel too
Tests passing does not prove the package still builds. One more step catches a broken pyproject.toml before a user does.
- run: pip install build
- run: python -m build
- run: pip install dist/*.whl
Package something of your own
The real exercise. The utility toolkit is an ideal first package: it is already a single module with no dependencies, so only the name and the entry point change.
[project]
name = "my-helpers"
version = "0.1.0"
[project.scripts]
# a library needs no entry point at all; delete this section
Download the files
The finished package: source layout, pyproject.toml, tests and workflow. Unzip it, then run pip install -e ".[dev]" and csvpeek --help.
- csvpeek.py (the script we start from) The one-file version, before any packaging.
- pyproject.toml (the finished config) Build system, metadata, dependencies, dev extras, and the entry point.
- test_summary.py (the test suite) Tests for the pure logic, ready for pytest and CI.
Mini exercise (medium)
Write the two small functions a release script needs. wheel_name(name, version) returns the filename python -m build would produce for a pure-Python package, normalizing the name the way packaging does, with hyphens becoming underscores. next_version(version, part) bumps "major", "minor" or "patch", resetting everything smaller.
Give it a shot. Complete the code and press Run; there’s nothing to download or configure.
def wheel_name(name, version):
"""The file `python -m build` produces for a pure-Python package."""
return "" # TODO
def next_version(version, part):
"""Bump 'major', 'minor' or 'patch', resetting anything smaller."""
return "" # TODO
print(wheel_name("csvpeek", "0.1.0"))
print(wheel_name("my-tool", "1.2.3"))
print(next_version("1.2.3", "patch"))
print(next_version("1.2.3", "minor"))
print(next_version("1.2.3", "major"))
The wheel filename is {name}-{version}-py3-none-any.whl with - replaced by _ in the name only. For the bump, map(int, version.split(".")) gives you three numbers; raising the major resets minor and patch to 0, raising the minor resets only the patch.
def wheel_name(name, version):
"""The file `python -m build` produces for a pure-Python package."""
return f"{name.replace('-', '_')}-{version}-py3-none-any.whl"
def next_version(version, part):
"""Bump 'major', 'minor' or 'patch', resetting anything smaller."""
major, minor, patch = (int(n) for n in version.split("."))
if part == "major":
return f"{major + 1}.0.0"
if part == "minor":
return f"{major}.{minor + 1}.0"
return f"{major}.{minor}.{patch + 1}"
print(wheel_name("csvpeek", "0.1.0"))
print(wheel_name("my-tool", "1.2.3"))
print(next_version("1.2.3", "patch"))
print(next_version("1.2.3", "minor"))
print(next_version("1.2.3", "major"))
csvpeek-0.1.0-py3-none-any.whl
my_tool-1.2.3-py3-none-any.whl
1.2.4
1.3.0
2.0.0
assert wheel_name("csvpeek", "0.1.0") == "csvpeek-0.1.0-py3-none-any.whl", "pure-Python wheels are py3-none-any"
assert wheel_name("my-tool", "1.2.3") == "my_tool-1.2.3-py3-none-any.whl", "hyphens in the NAME become underscores"
assert next_version("1.2.3", "patch") == "1.2.4"
assert next_version("1.2.3", "minor") == "1.3.0", "bumping the minor must reset the patch"
assert next_version("1.2.3", "major") == "2.0.0", "bumping the major must reset both"
assert next_version("0.9.9", "minor") == "0.10.0", "these are numbers, not decimals: 9 -> 10"
print("✓ Ready to release!")