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.

Advanced 60–75 minutes

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.

One loose script becomes a project, then an installable wheel, then a command that works from anywhere.

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.

Example · csvpeek.py
"""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()
A perfectly good script that only works if you are standing in the right folder.

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.

Example
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
Two folders and a config file. The split inside src/ is the same one the script already had.

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.

Example · pyproject.toml
[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"
Every line is doing something.

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.

Example
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()))
Output
module:   csv
function: list_dialects
calling:  ['excel', 'excel-tab', 'unix']
An entry point is an import and a getattr. That is the whole mechanism.

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.

Example
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__)
Output
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
Three tests, run here by hand. In the project, pytest finds them itself.

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.

Example
$ 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
Note the cd: the command works from anywhere now.

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.

Example · .github/workflows/tests.yml
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
Three Python versions, lint before tests, on every push and pull request.

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.

Example
$ 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
Email someone that .whl and they are one command from running your tool.

The finished project

The whole package, and the two files that carry all the new information.

Example · the finished tree
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()
The logic lives in summary.py, the interface in cli.py, and pyproject.toml ties it to the command name.

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.

Example
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.

Example
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.

Example
      - 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.

Example
[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.


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"))

Where to go next

That is the Advanced roadmap complete: your code is tested, packaged, installable, and checked automatically on every push.

The best next move is to package something you wrote yourself: the utility toolkit or the API tracker are both ready for it. Then plan something larger with the Unit 15 capstone worksheet.