Level 5 · In the Wild

Packaging and Shipping 📦

The final step from 'a folder of scripts' to 'a thing that exists in the world and someone else can pip install'.

The shape of a package

crew-manager/
├── pyproject.toml          the one config file that matters
├── README.md               what it is, shown on PyPI
├── LICENSE                 without one, nobody may legally use it
├── .gitignore
├── src/
│   └── crew_manager/
│       ├── __init__.py     makes it a package; holds the version
│       ├── core.py
│       └── cli.py
└── tests/
    ├── test_core.py
    └── test_cli.py
📁 Why src/

Putting the package inside src/ means your tests cannot accidentally import the local folder instead of the installed package. That sounds pedantic until the day your tests pass locally and the published package is missing a file nobody noticed. This is called the src layout and it is the current recommendation.

pyproject.toml

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "crew-manager"
version = "0.1.0"
description = "Manage a pirate crew from the command line."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [{ name = "Your Name" }]
keywords = ["cli", "pirates"]
classifiers = [
    "Programming Language :: Python :: 3",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
]
dependencies = [
    "rich>=13.0",
]

[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.6", "mypy>=1.10"]

[project.scripts]
crew = "crew_manager.cli:main"

[project.urls]
Homepage = "https://github.com/you/crew-manager"
Issues = "https://github.com/you/crew-manager/issues"

That [project.scripts] line is the good bit: after installing, the user gets a crew command on their PATH that calls your main function. It is how pytest, ruff and pip itself are installed.

Installing your own package while you work on it

$ python3 -m venv .venv && source .venv/bin/activate
$ pip install -e ".[dev]"

Successfully installed crew-manager-0.1.0 (editable)

$ crew --help
usage: crew [-h] {add,list,remove} ...

-e means editable: the package is installed as a link to your source, so your edits take effect immediately with no reinstall. .[dev] also pulls in the optional development dependencies. This is the first command to run in any Python project you clone.

Building and publishing

$ pip install build twine
$ python -m build

Successfully built crew_manager-0.1.0.tar.gz and
                   crew_manager-0.1.0-py3-none-any.whl

# ALWAYS publish to the test index first
$ twine upload --repository testpypi dist/*
$ pip install --index-url https://test.pypi.org/simple/ crew-manager

# then, when you are sure
$ twine upload dist/*
FileWhat it is
.whl (wheel)The built package. Fast to install: it is just unpacked
.tar.gz (sdist)The source. A fallback when a wheel does not fit the platform
🔒 Use a token, not your password

Create a PyPI API token scoped to the single project, and store it in ~/.pypirc or a CI secret. Better still, use trusted publishing, which lets GitHub Actions publish with no long-lived secret at all.

And note: a version once published can never be replaced. You can only yank it and publish a new number. Test on TestPyPI first, every time.

Version numbers mean something

ChangeBumpExample
Broke something that used to workMAJOR1.4.2 → 2.0.0
Added something, nothing brokeMINOR1.4.2 → 1.5.0
Fixed a bug, no interface changePATCH1.4.2 → 1.4.3
Still working it out0.x0.1.0. Anything may change

This is semantic versioning, and it is a promise to the people installing your package. Breaking it, by changing behaviour in a patch release, is the fastest way to lose users' trust, because their code broke while they were doing nothing wrong.

Automating the checks

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12", "3.13"]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install
        run: pip install -e ".[dev]"

      - name: Lint
        run: ruff check .

      - name: Format check
        run: ruff format --check .

      - name: Type check
        run: mypy src

      - name: Test
        run: pytest -v

Every push now runs your tests on three Python versions, on a clean machine, before you can merge. That last part is the real value: it catches "works on my machine" the moment it happens, and it proves your package installs from scratch.

VOLITION[Formidable: Success]

Publishing something, even something small, changes how you write code. You suddenly care about the interface, because changing it will inconvenience strangers.

It is also a rite of passage. Your name on a package that anyone in the world can install is a genuinely different feeling from a folder of scripts, and it is worth doing once even if nobody but you ever installs it.

Before you publish anything

Exercise 1

Package something you have written

Take any script from this course, give it the layout above, and install it editable with a console command.

Reveal solution
# src/wordtools/__init__.py
"""Small text statistics helpers."""

__version__ = "0.1.0"

from .core import word_count, most_common

__all__ = ["word_count", "most_common"]
# src/wordtools/cli.py
"""Command-line entry point."""

import argparse
import sys
from pathlib import Path

from .core import most_common, word_count


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(prog="wordtools")
    parser.add_argument("file", type=Path)
    parser.add_argument("-n", "--top", type=int, default=3)
    args = parser.parse_args(argv)

    try:
        text = args.file.read_text(encoding="utf-8")
    except FileNotFoundError:
        print(f"wordtools: no such file: {args.file}", file=sys.stderr)
        return 1

    print(f"{word_count(text)} words")
    for word, count in most_common(text, args.top):
        print(f"  {count:4}  {word}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
pip install -e ".[dev]"
wordtools README.md --top 5

With [project.scripts] wordtools = "wordtools.cli:main" in pyproject.toml, that command now exists on your PATH. You have built a real tool.

Exercise 2

Which version number?

You are at 2.3.1. What is the next version for each change?

  1. Fixed a crash on empty input.
  2. Added an optional --verbose flag.
  3. Renamed a function everyone uses.
  4. Made an argument required that used to be optional.
  5. Rewrote the internals, identical behaviour, three times faster.
Reveal solution
  1. 2.3.2. Patch.
  2. 2.4.0. Minor: new feature, nothing broken.
  3. 3.0.0. Major. Even with an alias left behind, it is a breaking change to the documented interface.
  4. 3.0.0. Major. Every existing call now fails, which is the definition of breaking.
  5. 2.3.2. Patch, arguably 2.4.0 if the speed is a headline feature. Users' code does not change either way.

The test is always the same: could someone else's working code break if they upgrade without reading anything?

Exercise 3

Read a package's manifest

Look at any package you use on PyPI and find: its licence, its minimum Python, its dependencies, when it was last released, and whether the source repository is linked and active. Then say whether you would depend on it.

Reveal solution

The signals that matter, roughly in order:

  • Recent releases or recent commits. Two years of silence means you will be maintaining it.
  • A permissive licence (MIT, BSD, Apache) unless you have checked that a copyleft one suits your use.
  • Few dependencies of its own. Every one is a package you are also trusting, transitively.
  • Issues being answered, even if not always fixed.
  • A name that is not one typo away from a much more popular package (Lesson 26).

Adding a dependency is a decision with a maintenance cost, not a free win. The standard library remains the safest dependency you will ever have.

+100 XP