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