Chapter 45 — Packaging: From a Code Folder to a Package

Series A1 — Professional Software Engineering

NoteIn this chapter, you will:
  • Organize a project as a package with the src layout
  • Write pyproject.toml, the package’s “ID card”
  • Build the package with python -m build and check it with twine check
  • Install the package in a fresh environment and use its terminal command
  • Understand the publishing flow (trusted publishing) without running it

All scripts in this chapter are in the folder kode/ch45/. In the web version, you can also download everything at once (zip).

In Chapter 44, our timber value summary became fast. But the code is still a set of loose files. If a colleague wants to use it, you must send files one by one, and they get confused about which version is newest. Imagine selling seedlings: loose seedlings in a sack are a hassle, while seedlings in a labelled package (name, version, planting instructions) are easy to ship and trust. A Python package is that packaging: code plus a label (metadata) that tools like pip understand.

From a code folder through build to sdist and wheel, then checked with twine check.

The folder structure of a package

01_structure.py
# Chapter 45 - Folder structure of a package (the "src" layout)
from pathlib import Path

root = Path(__file__).parent / "field_package"
print(root.name + "/")
for item in sorted(root.rglob("*"), key=lambda p: p.as_posix().lower()):
    if "__pycache__" in item.parts or "_tmp" in item.parts or item.suffix == ".egg-info":
        continue
    depth = len(item.relative_to(root).parts)
    mark = "/" if item.is_dir() else ""
    print("  " * depth + item.name + mark)
field_package/
  pyproject.toml
  README.md
  src/
    field_monitor/
      __init__.py
      cli.py
      compute.py
  tests/
    test_compute.py

This is called the src layout: the package code lives inside src/field_monitor/, separate from tests/. The advantage: when testing, you test the package imported exactly as a user would, not the working folder that happens to be nearby. Each part:

  • pyproject.toml: the ID card and the build instructions.
  • README.md: the explanation for users; it also becomes the package’s front page.
  • __init__.py: marks the folder as a package, and is where you choose what people may import.
  • cli.py: the terminal command interface.

pyproject.toml: the package ID card

The pyproject.toml file uses the TOML format (simple text shaped like key = value). Its content:

[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

[project]
name = "field-monitor-example"
version = "0.1.0"
description = "Example package from the book: timber value per plot and species"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
dependencies = []

[project.optional-dependencies]
fast = ["numpy>=2"]
test = ["pytest>=8"]

[project.scripts]
field-monitor = "field_monitor.cli:main"

(The excerpt above is shortened; the real file is in the zip and also adds classifiers and the package search in src.) Python has a built-in TOML reader, tomllib:

02_pyproject.py
# Chapter 45 - Reading pyproject.toml with tomllib (built into Python 3.11+)
import tomllib
from pathlib import Path

content = tomllib.loads((Path(__file__).parent / "field_package" / "pyproject.toml").read_text(encoding="utf-8"))

project = content["project"]
print("package name    :", project["name"])
print("version         :", project["version"])
print("needs Python    :", project["requires-python"])
print("dependencies    :", project["dependencies"])
print("optional extras :", sorted(project["optional-dependencies"]))
print("terminal command:", project["scripts"])
print("build tool      :", content["build-system"]["requires"], "->", content["build-system"]["build-backend"])
package name    : field-monitor-example
version         : 0.1.0
needs Python    : >=3.12
dependencies    : []
optional extras : ['fast', 'test']
terminal command: {'field-monitor': 'field_monitor.cli:main'}
build tool      : ['setuptools>=77'] -> setuptools.build_meta

The meaning of the important parts:

Part Purpose
[build-system] the tool used to build the package (here setuptools)
name, version the package name and version number
requires-python the minimum Python version
dependencies other packages that must be present (empty here)
optional-dependencies optional extras, installed with pip install "package[fast]"
[project.scripts] creates the terminal command field-monitor that calls the main function
NoteREMEMBER

The package name above deliberately ends in -example so it does not clash with a real package. Before naming your own package, check whether the name is already taken on PyPI (the Python package index). Version numbers follow the pattern MAJOR.MINOR.PATCH: raise PATCH for small fixes, MINOR for compatible new features, MAJOR when a change may break users’ code.

Using the package straight from src

Before installing, the package can be tried by putting src on Python’s import path:

03_import_from_src.py
# Chapter 45 - Using the package straight from the src folder (not installed)
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent / "field_package" / "src"))

import field_monitor

trees = [
    {"species": "Teak", "plot": "A", "diameter_cm": 32.5, "height_m": 18.0},
    {"species": "Meranti", "plot": "A", "diameter_cm": 45.2, "height_m": 22.0},
    {"species": "Teak", "plot": "B", "diameter_cm": 28.0, "height_m": 15.0},
]
print("version (not installed):", field_monitor.__version__)
for key, value in field_monitor.value_per_group(trees).items():
    print(key, f"Rp {value:,.0f}")
version (not installed): not-installed
('A', 'Meranti') Rp 4,412,646
('A', 'Teak') Rp 2,986,474
('B', 'Teak') Rp 1,847,255

Note the version: not-installed. In __init__.py, the version is read from the installed package’s metadata (importlib.metadata), so the only source of the version number is pyproject.toml. When not installed, we show a stand-in value instead of an error.

Building: sdist and wheel

The command python -m build produces two files in the dist/ folder:

  • sdist (source distribution, .tar.gz): an archive of the source code.
  • wheel (.whl): a finished package that pip can install quickly.

We build in the temporary folder _tmp/ to keep the working folder clean:

04_build.py
# Chapter 45 - Building the package (python -m build) in the temporary folder _tmp/
import shutil
import subprocess
import sys
from pathlib import Path

here = Path(__file__).parent
source = here / "field_package"
work = here / "_tmp" / "project"

shutil.rmtree(here / "_tmp", ignore_errors=True)
shutil.copytree(source, work, ignore=shutil.ignore_patterns("__pycache__", "*.egg-info", "_tmp"))

# --no-isolation: use the setuptools that is already installed, so no internet is needed.
# Without that option (the usual way), "build" makes a separate environment and downloads the build tools.
result = subprocess.run(
    [sys.executable, "-m", "build", "--no-isolation", str(work)],
    capture_output=True, text=True, encoding="utf-8",
)
print("build exit code:", result.returncode)
for line in result.stdout.splitlines():
    if line.startswith("Successfully built"):
        print(line)
print("contents of the dist folder:")
for item in sorted((work / "dist").iterdir()):
    print(" ", item.name)
build exit code: 0
Successfully built field_monitor_example-0.1.0.tar.gz and field_monitor_example-0.1.0-py3-none-any.whl
contents of the dist folder:
  field_monitor_example-0.1.0-py3-none-any.whl
  field_monitor_example-0.1.0.tar.gz
WarningWARNING

The normal command is just python -m build. It creates an isolated environment and downloads the build tools from the internet. Our script uses --no-isolation so it runs without internet using the setuptools that is already installed. On your computer, plain python -m build is enough. (You must install the build tool first with pip install build.)

twine check and the wheel contents

twine check verifies that the package metadata is valid and that the README can be displayed correctly on PyPI. It only checks; it uploads nothing.

05_twine_check.py
# Chapter 45 - twine check: verifying the package metadata (does NOT upload anything)
import os
import re
import subprocess
import sys
from pathlib import Path

dist = Path(__file__).parent / "_tmp" / "project" / "dist"
files = sorted(str(p) for p in dist.iterdir())

# wide COLUMNS and NO_COLOR so twine output is neither cut off nor coloured
env = dict(os.environ, COLUMNS="400", NO_COLOR="1")
result = subprocess.run(
    [sys.executable, "-m", "twine", "check", *files],
    capture_output=True, text=True, encoding="utf-8", env=env,
)
text = re.sub(r"\s+", " ", result.stdout)
for name, status in re.findall(r"Checking \S*?([\w.\-]+\.(?:whl|tar\.gz)): (\w+)", text):
    print(f"{name}: {status}")
print("twine check exit code:", result.returncode)
field_monitor_example-0.1.0-py3-none-any.whl: PASSED
field_monitor_example-0.1.0.tar.gz: PASSED
twine check exit code: 0

A wheel turns out to be just a zip file. Let us peek inside:

06_wheel_contents.py
# Chapter 45 - Peeking inside a wheel file (it is really a zip)
import zipfile
from pathlib import Path

dist = Path(__file__).parent / "_tmp" / "project" / "dist"
wheel = next(dist.glob("*.whl"))
print("wheel name:", wheel.name)

with zipfile.ZipFile(wheel) as z:
    for name in sorted(z.namelist()):
        print(" ", name)
    metadata = z.read(next(n for n in z.namelist() if n.endswith("METADATA"))).decode("utf-8")

print("--- part of METADATA ---")
for line in metadata.splitlines()[:8]:
    print(line)
wheel name: field_monitor_example-0.1.0-py3-none-any.whl
  field_monitor/__init__.py
  field_monitor/cli.py
  field_monitor/compute.py
  field_monitor_example-0.1.0.dist-info/METADATA
  field_monitor_example-0.1.0.dist-info/RECORD
  field_monitor_example-0.1.0.dist-info/WHEEL
  field_monitor_example-0.1.0.dist-info/entry_points.txt
  field_monitor_example-0.1.0.dist-info/top_level.txt
--- part of METADATA ---
Metadata-Version: 2.4
Name: field-monitor-example
Version: 0.1.0
Summary: Example package from the book: timber value per plot and species
License-Expression: MIT
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.12

Only the package code (field_monitor/) and a dist-info folder with metadata go in. The tests/ folder does not come along, because we only look for packages in src. The METADATA lines hold the name, version, summary, and Requires-Python that we wrote in pyproject.toml.

Installing and using it

The best proof that a package works is to install it in a clean place, as if you were another user. The following script creates a new virtual environment, installs the wheel, then runs its terminal command:

07_install_and_use.py
# Chapter 45 - Running project A1: install the wheel in a NEW virtual environment and use it
import subprocess
import sys
from pathlib import Path

root = Path(__file__).parent / "_tmp"
wheel = next((root / "project" / "dist").glob("*.whl"))
venv = root / "venv_test"
bin_dir = venv / ("Scripts" if sys.platform == "win32" else "bin")
exe = ".exe" if sys.platform == "win32" else ""

subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
new_python = str(bin_dir / f"python{exe}")

# --no-index --no-deps: no internet, and our package has no dependencies anyway
install = subprocess.run(
    [new_python, "-m", "pip", "install", "--no-index", "--no-deps", "--quiet", str(wheel)],
    capture_output=True, text=True,
)
print("install succeeded:", install.returncode == 0)

version = subprocess.run(
    [new_python, "-c", "import field_monitor as p; print(p.__version__)"],
    capture_output=True, text=True,
)
print("installed version:", version.stdout.strip())

cli = subprocess.run([str(bin_dir / f"field-monitor{exe}"), "--count", "1000"],
                     capture_output=True, text=True, encoding="utf-8")
print("--- output of the command field-monitor --count 1000 ---")
print(cli.stdout.rstrip())
install succeeded: True
installed version: 0.1.0
--- output of the command field-monitor --count 1000 ---
12 groups from 1000 trees
  plot A Mahogany Rp 191,426,619
  plot A Meranti  Rp 167,648,188
  plot A Sengon   Rp 71,666,209

The field-monitor command exists because of the [project.scripts] section above. The installed version reads 0.1.0 from the metadata, no longer not-installed. We only use --no-index --no-deps so the script does not need internet.

Testing the package

08_test_package.py
# Chapter 45 - Running the package tests with pytest (against the code in the src folder)
import os
import re
import subprocess
import sys
from pathlib import Path

project = Path(__file__).parent / "field_package"
env = dict(os.environ, PYTHONPATH=str(project / "src"))

result = subprocess.run(
    [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", str(project / "tests")],
    capture_output=True, text=True, encoding="utf-8", env=env, cwd=str(project),
)
summary = re.search(r"(\d+) passed", result.stdout)
print("tests passed:", summary.group(1) if summary else "none")
print("pytest exit code:", result.returncode)
tests passed: 2
pytest exit code: 0

Two tests pass. We run pytest against the code in src through PYTHONPATH. In a real project, you usually install the package in editable mode (pip install -e .) and then run pytest.

The publishing flow: trusted publishing (not run)

To share a package with everyone, you upload it to PyPI. The official documentation (packaging.python.org, read 2026-10-10) recommends Trusted Publishing: instead of keeping a secret token, PyPI trusts one specific GitHub Actions workflow of yours. In short:

  1. On PyPI, register a “trusted publisher”: the GitHub repository and an environment named pypi.
  2. On GitHub, create a workflow that builds the package, then publishes it only when a release tag is pushed.
  3. The workflow is given the id-token: write permission so GitHub can prove its identity to PyPI.

A workflow excerpt from the official documentation (simplified; only the publishing part):

publish-to-pypi:
  if: startsWith(github.ref, 'refs/tags/')   # only on release tags
  needs:
    - build
  runs-on: ubuntu-latest
  environment:
    name: pypi
    url: https://pypi.org/p/<package-name>
  permissions:
    id-token: write   # mandatory for trusted publishing
  steps:
    - name: Download all the dists
      uses: actions/download-artifact@v6
      with:
        name: python-package-distributions
        path: dist/
    - name: Publish distribution to PyPI
      uses: pypa/gh-action-pypi-publish@release/v1

This code was not run when the book was built because uploading to PyPI changes the outside world and must not be done for a book example, so no output is shown. We did not upload this example package to PyPI or TestPyPI.

Running Project A1: the timber summary becomes a package

Scripts 04 to 08 above are also the project steps: the function value_per_group from Chapter 44 now lives in the package field-monitor-example version 0.1.0, passes twine check, installs in a clean environment, can be called through the field-monitor command, and is tested (2 tests pass). This package version deliberately does not use numpy (one pass over the data with a dict is enough), and numpy is placed as the optional extra fast. That is a design decision: a package with few dependencies is easier to install. In Chapter 46, we reorganize its architecture.

Exercises

  1. Change version in pyproject.toml (your copy) to 0.1.1, rebuild, and look at the file names in dist/.
  2. Add a second terminal command in [project.scripts] that calls another function. Reinstall and try it.
  3. Delete README.md from your copy of the project and run python -m build. What happens?
  4. Challenge: add [project.urls] (for example Homepage) and run the CLI with an invalid option value (--count abc); observe the friendly argparse error message.

Chapter Summary

  • A package = code + metadata. The src layout separates package code from tests.
  • pyproject.toml is one configuration file: build, name, version, dependencies, terminal commands.
  • python -m build produces an sdist and a wheel; twine check verifies metadata without uploading.
  • Prove the package by installing it in a clean environment.
  • Official publishing uses Trusted Publishing from GitHub Actions; in this book it is only explained, not run.

Next, in Chapter 46: design patterns and architecture, keeping code tidy as an application grows.