Skip to content
System nominalUTC --:--:--Articles 041
MODULE 06 Lesson 18 / 18 40 MIN

Concurrency and shipping

Packaging a Service With Pinned Dependencies

A service is a wheel, an entry point, configuration from the environment and a shutdown path. This lesson packages the auditor the course has been building.

The script worked on your machine. On the server it failed at import with ModuleNotFoundError, and after that was fixed it failed again because the version pip resolved was newer than the one you tested. The code never changed. The environment did, and nothing in the repository described the environment the code needed.

Packaging is how you stop guessing. A wheel is a build artefact with an exact version and an explicit dependency list, and installing it is a reproducible act rather than a sequence of remembered commands. The auditor built across this module gets packaged here, and it already has the two properties that make a service packageable: it reads its configuration from the environment, and it reports what it did as structured lines rather than prose.

pyproject.toml declares the project, not the commands

One file at the repository root describes what the package is, what it needs and how to build it. Every modern build backend reads this format, so the contents below survive a change of tool.

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

[project]
name = "wp-audit"
version = "0.4.0"
description = "Audit a WordPress REST API for post length and freshness, concurrently."
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
dependencies = [
    "httpx>=0.28,<0.29",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.3",
    "ruff>=0.9",
    "build>=1.2",
]

[project.scripts]
wp-audit = "wp_audit.__main__:main"

[tool.hatch.build.targets.wheel]
packages = ["src/wp_audit"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 100
target-version = "py312"

requires-python is the metadata that matters most and the one people leave out. Without it, pip will install your package on an interpreter that lacks the syntax you used, and the user gets a SyntaxError at import with no explanation. The course has used 3.12 features since module 4, so >=3.12 is the honest floor. name is the distribution name and is not always the import name: this package is wp-audit on an index and wp_audit in an import statement. The build backend normalises the hyphen.

The [build-system] table is not decoration. requires tells the build frontend what to install in an isolated environment before it runs the backend, and build-backend names the module it then calls. The example uses hatchling because a src layout needs no further configuration beyond knowing where the package lives. Setuptools would work too; the difference is the amount of configuration, not the output.

Runtime dependencies and development dependencies have different audiences

dependencies is what someone installing your wheel gets. [project.optional-dependencies] is what someone working on your source gets, and the two lists should not overlap by accident. The auditor's runtime dependency is one package. Its test runner, linter and build frontend are not dependencies of the service and should never be installed on the server that runs it.

Installing for development uses the extra by name:

python -m venv .venv
.venv/bin/python -m pip install -e ".[dev]"
Building editable for wp-audit (pyproject.toml): finished with status 'done'
Created wheel for wp-audit: filename=wp_audit-0.4.0-py3-none-any.whl size=1759
Successfully built wp-audit
Installing collected packages: wp-audit
Successfully installed wp-audit-0.4.0

On Windows the interpreter lives at .venv\Scripts\python.exe, which is the only part that changes. The quotes around .[dev] are load bearing in zsh, which would otherwise fail to match the pattern. The -e flag installs the project in editable mode, so source edits take effect without reinstalling. Use it while developing and never in a deployment: an editable install keeps a path back to your checkout, which is exactly the coupling a wheel exists to remove.

python -m venv is in the standard library and needs nothing else. It creates a directory with its own interpreter, its own site-packages and its own pip, and activating it changes PATH so that python resolves to the copy inside the environment. Tools such as uv, Poetry and PDM do the same job with faster resolution. There is no correctness argument for one over another; there is an operational argument for using what your team already has, because a build system nobody can reproduce is worse than a slow one.

Ranges for libraries, pins for applications

httpx>=0.28,<0.29 is a range, and it is the right shape for the dependency list in pyproject.toml. A range says which versions you have tested against and lets pip pick the newest compatible one. A pin, httpx==0.28.1, names one version and turns every resolution into a conflict waiting to happen when that package sits alongside anything else.

Ranges are not enough for a service, because the set of versions pip chooses today is not the set it will choose next month. The answer is a lock file: an exact list of every package and version, including transitive dependencies, that you commit and install from.

python -m venv .venv
.venv/bin/python -m pip install wp-audit-0.4.0-py3-none-any.whl
.venv/bin/python -m pip freeze > requirements.lock
anyio==4.15.1
certifi==2026.7.22
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.20
typing_extensions==4.16.0
wp-audit @ file:///private/tmp/m6/wp-audit/dist/wp_audit-0.4.0-py3-none-any.whl#sha256=802590011b4e26d20e3b5845afca28d4dcd2f4592716005825124b6c5306612b

That is the entire runtime closure of the wheel: one direct dependency and six packages it pulls in. Running freeze in the environment built from the wheel is what makes the list complete, and the last line shows why. It records the wheel you installed, path and hash included, rather than pretending the project came from an index. Published from a real index the line reads wp-audit==0.4.0.

pip freeze has a flaw worth knowing about. It records the whole environment, so anything you installed for convenience becomes part of the service's declared dependencies. Tools such as uv pip compile and pip-tools read pyproject.toml, resolve it, and write a lock file containing only what the project actually needs, with hashes. That is the better workflow once a project has more than a handful of dependencies.

What the lock file does not cover

A lock file pins Python packages. It says nothing about the interpreter version, the system libraries, or the operating system, and a wheel with compiled extensions may not be portable at all. Pin the interpreter in your deployment configuration too, or you have locked half the problem.

Building a wheel produces the artefact the service runs from

The build frontend is a separate package, installed as a development dependency:

python -m build
* Building sdist...
* Building wheel from sdist
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling>=1.27
* Building wheel...
Successfully built wp_audit-0.4.0.tar.gz and wp_audit-0.4.0-py3-none-any.whl

Two artefacts land in dist/. The sdist is the source tree; the wheel is the built package, and its filename carries the version, the Python tag and the platform tag. py3-none-any means pure Python and any platform. A wheel with a compiled extension has a platform tag like macosx_11_0_arm64 and will not install on Linux, which is why some projects publish many of them.

The wheel is a zip file, and looking inside it is the fastest way to understand what you are shipping:

wp_audit/__init__.py
wp_audit/__main__.py
wp_audit/audit.py
wp_audit/config.py
wp_audit/logging_setup.py
wp_audit-0.4.0.dist-info/METADATA
wp_audit-0.4.0.dist-info/WHEEL
wp_audit-0.4.0.dist-info/entry_points.txt
wp_audit-0.4.0.dist-info/RECORD
[console_scripts]
wp-audit = wp_audit.__main__:main

That entry point is what makes wp-audit a command. pip writes a small launcher script into the environment's bin/ directory, and running wp-audit imports wp_audit.__main__ and calls main, whose return value becomes the process exit status. Exit statuses are part of the interface: 0 for success, 1 for a failed audit, 2 for bad configuration, 130 for an interrupt. A supervisor reads those codes, and a service that always returns 0 tells its supervisor nothing.

python -m venv /srv/wp-audit/venv
/srv/wp-audit/venv/bin/python -m pip install wp_audit-0.4.0-py3-none-any.whl
/srv/wp-audit/venv/bin/wp-audit

That is a deployment. No checkout, no build step on the server, no compiler. The service runs python -m wp_audit from a source checkout during development and the installed console script in production, and both paths reach the same main.

Configuration comes from the environment, and validation is not optional

A service that reads a config file has to know where that file is, and the answer differs between your laptop, a container and a systemd unit. Environment variables are the one interface every supervisor can set, so the service reads them and nothing else.

"""Configuration read from the environment, with defaults and validation."""

from __future__ import annotations

import os
from dataclasses import dataclass


@dataclass(frozen=True)
class Settings:
    base_url: str
    concurrency: int
    timeout: float
    min_words: int
    max_age_days: int
    log_level: str

    @classmethod
    def from_env(cls, env: dict[str, str] | None = None) -> Settings:
        source = os.environ if env is None else env

        def text(name, default):
            return source.get(name, default).strip()

        def number(name, default, cast, minimum):
            raw = text(name, str(default))
            try:
                value = cast(raw)
            except ValueError as error:
                raise ValueError(f"{name} must be a number, got {raw!r}") from error
            if value < minimum:
                raise ValueError(f"{name} must be at least {minimum}, got {value}")
            return value

        return cls(
            base_url=text("WP_AUDIT_BASE_URL", "http://127.0.0.1:8801").rstrip("/"),
            concurrency=number("WP_AUDIT_CONCURRENCY", 8, int, 1),
            timeout=number("WP_AUDIT_TIMEOUT", 10.0, float, 0.1),
            min_words=number("WP_AUDIT_MIN_WORDS", 300, int, 0),
            max_age_days=number("WP_AUDIT_MAX_AGE_DAYS", 365, int, 1),
            log_level=text("WP_AUDIT_LOG_LEVEL", "INFO").upper(),
        )
WP_AUDIT_CONCURRENCY=zero python -m wp_audit
configuration error: WP_AUDIT_CONCURRENCY must be a number, got 'zero'

The service exits 2 rather than crashing with a traceback, and the message names the variable and the value. That is the difference between a five-second fix and a support thread. Taking the environment as an argument to from_env also makes the class testable without touching the real environment, which is why the test suite can assert on the failure above.

Every default in that class is a decision, and defaults are what production will actually run. Write them down in the README next to the variable name.

Structured logs and a shutdown path

A log line that reads "processed 4 posts" is useless to a log aggregator. A JSON object with a timestamp, a level, an event name and named fields is queryable, and the format costs one class:

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
            "level": record.levelname,
            "logger": record.name,
            "event": record.getMessage(),
        }
        for key, value in getattr(record, "context", {}).items():
            payload[key] = value
        if record.exc_info:
            payload["error"] = self.formatException(record.exc_info).splitlines()[-1]
        return json.dumps(payload, ensure_ascii=False)


def configure_logging(level: str = "INFO") -> logging.Logger:
    handler = logging.StreamHandler(sys.stderr)
    handler.setFormatter(JsonFormatter())
    root = logging.getLogger()
    root.handlers[:] = [handler]
    root.setLevel(level)
    # httpx logs one INFO line per request; that is noise at our level.
    logging.getLogger("httpx").setLevel(logging.WARNING)
    return logging.getLogger("wp_audit")
{"ts": "2026-10-16T18:17:17.387+00:00", "level": "INFO", "logger": "wp_audit", "event": "service.start", "base_url": "http://127.0.0.1:8802", "concurrency": 4}
{"ts": "2026-10-16T18:17:18.935+00:00", "level": "WARNING", "logger": "wp_audit", "event": "post.flagged", "slug": "a-short-note", "words": 90, "age_days": 40, "problems": "short:90"}
{"ts": "2026-10-16T18:17:18.935+00:00", "level": "INFO", "logger": "wp_audit", "event": "audit.done", "posts": 4, "flagged": 2, "total_words": 2350, "worst": ["a-short-note", "autoload-bloat-revisited"]}

Logs go to stderr, not stdout, so that a subcommand that prints a report to stdout does not have its output interleaved with diagnostics. The event name is a stable string you can group by, and the fields are typed in the JSON, so words is a number and not the string "90".

Shutdown is the other half. The supervisor sends SIGTERM, and the default behaviour is to die immediately, which drops whatever was in flight and leaves the caller holding a closed connection. Instead, the loop turns the signal into an event, the code stops starting new work, and the in-flight requests are cancelled through the normal path:

async def run_audit(settings: Settings, stopping: asyncio.Event) -> tuple[list[Finding], bool]:
    """Audit the site, or give up when stopping is set. Returns (findings, aborted)."""
    async with httpx.AsyncClient(base_url=settings.base_url, timeout=settings.timeout) as client:
        work = asyncio.create_task(audit(client, settings), name="audit")
        halt = asyncio.create_task(stopping.wait(), name="halt")
        done, _ = await asyncio.wait({work, halt}, return_when=asyncio.FIRST_COMPLETED)
        if work in done:
            halt.cancel()
            return work.result(), False
        work.cancel()
        try:
            await work
        except asyncio.CancelledError:
            pass
        return [], True


async def run(settings: Settings) -> int:
    logger = configure_logging(settings.log_level)
    stopping = asyncio.Event()
    loop = asyncio.get_running_loop()

    for name in ("SIGINT", "SIGTERM"):
        try:
            loop.add_signal_handler(getattr(signal, name), stopping.set)
        except NotImplementedError:  # Windows: the loop has no SIGTERM handler
            pass

    log_event(logger, "service.start", base_url=settings.base_url, concurrency=settings.concurrency)

    failure: int | None = None
    try:
        async with asyncio.timeout(settings.timeout * 4):
            findings, aborted = await run_audit(settings, stopping)
    except* (TimeoutError, httpx.HTTPError) as group:
        error = group.exceptions[0]
        if isinstance(error, TimeoutError):
            log_event(logger, "audit.timeout", level=40)
        else:
            log_event(logger, "audit.failed", level=40, error=type(error).__name__)
        failure = 1

    if failure is not None:
        return failure

    if aborted:
        log_event(logger, "service.stopped", level=30, reason="signal")
        return 130

    for finding in findings:
        if finding.problems:
            log_event(
                logger,
                "post.flagged",
                level=30,
                slug=finding.slug,
                words=finding.words,
                age_days=finding.age_days,
                problems=",".join(finding.problems),
            )
    log_event(logger, "audit.done", **summarise(findings))
    return 0

Two details in there are easy to get wrong. A return is not allowed inside an except* block, so the failure path records a value and acts on it afterwards. And except* catches both shapes of error, a bare TimeoutError and an ExceptionGroup from a TaskGroup, which is why one clause covers the deadline and the HTTP failure without inspecting types by hand.

kill -TERM $(pgrep -f "wp_audit|wp-audit")
{"ts": "2026-10-16T18:17:21.778+00:00", "level": "INFO", "logger": "wp_audit", "event": "service.start", "base_url": "http://127.0.0.1:8802", "concurrency": 4}
{"ts": "2026-10-16T18:17:22.091+00:00", "level": "WARNING", "logger": "wp_audit", "event": "service.stopped", "reason": "signal"}

The process waited for the cancellation to unwind, logged the reason, and exited 130: 128 plus the SIGTERM signal number, the convention that tells a supervisor the process was asked to stop rather than crashing. A long-running worker needs the same shape plus a deadline of its own, because a background loop that ignores the event hangs the shutdown until the supervisor escalates to SIGKILL.

Lab 45 minutes

Take a script you have written in this course that takes at least one argument on the command line. Turn it into a package with a src layout, a pyproject.toml, one console script entry point and a dev extra that holds your test runner.

Build a wheel, install it into a fresh virtual environment outside your checkout, and run the console script by name. Then produce a lock file for the runtime dependencies and install from it into a second fresh environment.

Finally, answer two questions. Which of your arguments should stay on the command line and which should come from the environment, and why? What exit status should the script return when it is interrupted, and what does your supervisor do with that number?

Show the solution and the reasoning

The layout is the one the auditor uses, and the important part is that src is a real directory level rather than a style choice:

my-tool/
  pyproject.toml
  README.md
  src/my_tool/
    __init__.py
    __main__.py
    cli.py
  tests/
    test_cli.py

With a flat layout, the package directory sits next to tests and the tests import it by accident, straight from the working directory, whether or not the package installs correctly. The src layout forces an install before the import works, so an import that succeeds in CI is a real signal about the wheel.

The command line and the environment answer different questions. Configuration that changes between deployments goes in the environment: the base URL, credentials, concurrency, log level. Configuration that changes between invocations goes on the command line: a one-off filter, a dry-run flag, the name of the thing you are looking at right now. The test is whether you would want two different values at the same time on the same host. If the answer is yes, the value belongs on the command line.

For exit statuses, the convention that supervisors and shells expect is 0 for success, 1 for a failure you detected and reported, 2 for a usage or configuration error, and 128 plus the signal number when a signal killed you. Returning 130 for SIGTERM and 143 for SIGINT is common in the other direction; pick the pair your language and platform produce and document it, because the important property is that the number is stable and non-zero. A supervisor configured with Restart=on-failure will keep restarting a process that exits 1, and will leave alone a process that exited 0 after being asked to stop. Getting the codes wrong turns a clean shutdown into a restart loop.

One thing this lesson does not solve: neither pyproject.toml nor a lock file pins the interpreter, the system libraries or the container base image. If your service needs a compiled dependency, the wheel you build on a laptop will not install on the server, and the fix is to build in the same image you deploy. That belongs in your CI configuration, not in this file.

Progress

Mark this lesson done and it stays marked in this browser.

Course complete

That is the last lesson of Python from First Script to Service. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.