Docker

This walk is one of several through the project. The website of the first, cheesecracker, lists them all.

We put cheesecracker, the API server of the Cheese App, into Docker.

Docker runs programs in containers. A container is a process with its own files, cut off from the rest of the machine. It starts from an image, a small Linux system with the program and everything that the program needs. A file named Dockerfile lists the instructions that build an image. docker build makes the image, and docker run starts a container from it. On macOS and Windows, the app Docker Desktop runs Docker.

An image runs the same on any machine with Docker, so the server runs for the real users of the app as it runs here. We build two images from one lockfile, uv.lock, which names the exact version of each package of the project. So both images hold the same versions. The run image is the server as it runs for the real users of the app, which we call production. It is our deployment.

The dev image holds the project with every tool. This tutorial is the part of the whole tutorial of cheesecracker that adds Docker.

The images depend on the groups of dependencies of the project. The group test holds the tools of the tests, and the group lint holds ruff and pre-commit. The run image installs no group, and the dev image installs every group. The tutorial uv, beside this one, shows how the groups came to be.

Two tools run the project on this machine. uv installs Python and the packages of the project into the folder .venv, at the exact versions of uv.lock. just runs each task of the project by a short name. Each task is a recipe in the file justfile. just setup runs uv sync --locked, which makes uv install everything into .venv, and just alone lists the recipes.

The code is in the repository rahuldave/cheesecracker on GitHub. When the project is done, the server's code is in src/api-service/api/, and its tests are in tests/unit/, tests/integration/ and tests/system/. pyproject.toml and uv.lock list the Python packages that the project needs. justfile holds its recipes, short names for the commands of the project. Dockerfile holds the instructions that build its two Docker images, the run image of the server and the dev image with every tool.

The repository keeps every saved version of the project, in the order of the whole tutorial of cheesecracker. That tutorial has many parts, and this tutorial follows some of them.

On the website, each page covers one part of the tutorial. The top of the page links to the code as it is when the part starts. The bottom links to the code as it is when the part ends.

We make the project in moves. A move is one small change, made by one patch or by one command. You make each move, and the page checks your files against it.

The tutorial starts from the project as the whole tutorial leaves it before Docker. The server answers /, /health and /euclidean_distance/. It has unit and integration tests, coverage, ruff, and pre-commit. Each task is a recipe, and just check runs the format, the lint and the tests.

How the walk is cut

Each page is one part, a stage of the project. At the end of every part, the project works. It runs, and its checks pass. A tutorial cuts each part into moves, one small change each, and a move is a section of its page. Between two moves, the code can be broken on purpose. For example, a move adds a test that fails, and the next move makes it pass. A part never ends broken.

Working is not the same as right. A part can end with code that runs and passes its checks. That code can still be wrong for the problem, such as a mask that leaves out the wrong rows. Such a mistake is part of the story, and a later part finds it and fixes it.

The code where a part starts and where it ends has a tag on GitHub, such as step-07, or is a commit between two tags. The top of each page says where its part starts, and the bottom says where it ends, each with a link to that code.

There are two ways to read this tutorial. Both have the same parts, in the same order.

Each part ends with a section called What changed. It lists every file that the part changes, with a link to the file, and then the whole diff of each file, folded. A lockfile and a binary file have a link alone. The text above it explains the change. In Build it yourself, What changed is what you check your files against.

In Build it yourself, a file that a command makes says which command. A diff that is open is a patch. Apply it with the command in the text, or make the same change in your editor.

Read the tutorial

Each change explained, with the code to look at.

Build it yourself

Every command to run and every change to make by hand, to make the project yourself.

Get the project

You build the project in a clone of your own, on a branch mine that starts where this walk starts. Run the commands of these pages in its top folder.

git clone https://github.com/rahuldave/cheesecracker
cd cheesecracker
git switch -c mine 57ec2e24ecd4

Then add the command git check, which compares your files with the project's after each move, and leaves what git add has staged as it was. It compares pyproject.toml and uv.lock by the names of their packages, so another version of a package does not count as a difference. A small Python program, package_names.py, reads each of these files as data, and uv runs it. These commands write the program into the folder .git, and tell git to show each file as the program prints it.

cat > "$(git rev-parse --git-common-dir)/package_names.py" <<'PY'
"""Print a pyproject.toml or a uv.lock without the versions of its packages, so that git check compares their names.

    python package_names.py pyproject.toml FILE
    python package_names.py uv.lock FILE

A pyproject.toml keeps all it says, with each requirement cut to its name, its extras, its address and its marker. A
uv.lock keeps the names that the project's own packages ask for, and the members of the workspace. The rules are those
of timewalk's package_names. The result is JSON, one item a line. A file that is not TOML is printed as it is.
"""

import json
import re
import sys
import tomllib

# A requirement such as "pytest>=8.3.4", "Uvicorn[standard] ; python_version < '3.12'" or "x @ git+https://...": its name,
# its extras, what comes between (the versions, or "@" and an address), and its marker after the ";".
REQUIREMENT = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?([^;]*)(?:;(.*))?$", re.S)


def package_names(
    path: str,  # The file's path in the project; its name says how to read it
    data: bytes,  # Its contents
) -> object | None:  # What it says without the versions of packages, to compare; None if it is not a file of packages
    """Read a pyproject.toml or a uv.lock with the versions left out, as timewalk's package_names does."""
    name = path.rsplit("/", 1)[-1]
    if name not in ("pyproject.toml", "uv.lock"):
        return None
    try:
        doc = tomllib.loads(data.decode("utf-8"))
    except (UnicodeDecodeError, tomllib.TOMLDecodeError):
        return None

    def cut(requirement: object) -> object:   # "pytest>=8.3.4" is pytest; a table, such as { include-group = "test" }, stays
        found = REQUIREMENT.match(requirement) if isinstance(requirement, str) else None
        if not found:
            return requirement
        extras = sorted(e.strip().lower() for e in (found.group(2) or "[]")[1:-1].split(",") if e.strip())
        between, marker = found.group(3).strip(), " ".join((found.group(4) or "").split())
        address = between[1:].strip() if between.startswith("@") else ""   # a package from an address: the address counts
        return (re.sub(r"[-_.]+", "-", found.group(1)).lower() + (f"[{','.join(extras)}]" if extras else "")
                + (f" @ {address}" if address else "") + (f"; {marker}" if marker else ""))

    def names(value: object) -> list:   # every name = "..." in a table, at any depth
        if isinstance(value, dict):
            return [*([value["name"]] if isinstance(value.get("name"), str) else []), *(n for v in value.values() for n in names(v))]
        return [n for v in value for n in names(v)] if isinstance(value, list) else []

    if name == "uv.lock":
        own = [p for p in doc.get("package", []) if isinstance(p, dict) and isinstance(p.get("source"), dict)
               and {"editable", "virtual"} & set(p["source"])]
        return {"requires-python": doc.get("requires-python"), "manifest": sorted(set(names(doc.get("manifest", {})))),
                "own": {p.get("name"): sorted(set(names({k: v for k, v in p.items() if k not in ("name", "version", "source")})))
                        for p in own}}
    lists = [("project", "dependencies"), ("build-system", "requires"), ("tool", "uv", "dev-dependencies")]
    groups = [("project", "optional-dependencies"), ("dependency-groups",)]

    def at(keys: tuple) -> object:   # the table or list at a path of keys, or None
        value = doc
        for key in keys:
            value = value.get(key) if isinstance(value, dict) else None
        return value

    for keys in lists:
        if isinstance(at(keys), list):
            at(keys[:-1])[keys[-1]] = sorted(map(cut, at(keys)), key=repr)
    for keys in groups:
        table = at(keys)
        for group, wanted in (table.items() if isinstance(table, dict) else []):
            if isinstance(wanted, list):
                table[group] = sorted(map(cut, wanted), key=repr)
    return doc


def main() -> int:  # The exit code
    """Print a file as package_names reads it, or as it is when it is not TOML."""
    kind, file = sys.argv[1], sys.argv[2]
    with open(file, "rb") as handle:
        data = handle.read()
    found = package_names(kind, data)
    if found is None:
        sys.stdout.buffer.write(data)
    else:
        print(json.dumps(found, indent=1, sort_keys=True, ensure_ascii=False, default=str))
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY
echo '/pyproject.toml diff=pyproject' >> .git/info/attributes
echo '/uv.lock diff=uvlock' >> .git/info/attributes
git config diff.pyproject.textconv 'uv run --quiet --no-config --no-project --python ">=3.11" python "$(git rev-parse --git-common-dir)/package_names.py" pyproject.toml'
git config diff.uvlock.textconv 'uv run --quiet --no-config --no-project --python ">=3.11" python "$(git rev-parse --git-common-dir)/package_names.py" uv.lock'
git config alias.check '!f() { i="$(git rev-parse --git-path check-index)" && { cp -p "$(git rev-parse --git-path index)" "$i" 2>/dev/null || rm -f "$i"; } && export GIT_INDEX_FILE="$i" && git add -A && git diff --cached "$1" -- ":/"; }; f'

The parts