Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

`compose2pod` is a stdlib-only Python package and CLI that converts a Docker Compose document into a
POSIX `sh` script running one service and its dependencies as a single Podman pod;
[`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the vocabulary — read it before naming a
[`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the vocabulary. Read it before naming a
concept in code, a test name, or an issue title.

## Commands

`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth —
`just --list`, or read it. Every recipe carries its intent as a comment. The one thing it does not
say: **never run bare `ruff check`** — `[tool.ruff]` sets `fix = true` and `unsafe-fixes = true`, so
`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth:
run `just --list`, or read it. Every recipe carries its intent as a comment. The one thing it does not
say: **never run bare `ruff check`**. `[tool.ruff]` sets `fix = true` and `unsafe-fixes = true`, so
an unqualified invocation rewrites the tree.

## Architecture

The pipeline is `read → resolve_extends → validate → emit_script`, one module per stage, each named
for what it does; read them. What reading a single module will **not** tell you:
for what it does; read them. What reading a single module will not tell you:

- `validate()` (`parsing.py`) is the gate, but it is not the only door: `emit._plan` — the single
traversal both public entry points (`emit_script`, `referenced_variables`) project from — calls
- `validate()` (`parsing.py`) is the gate, but it is not the only door: `emit._plan`, the single
traversal both public entry points (`emit_script`, `referenced_variables`) project from, calls
`validate()` itself, so a library caller cannot reach either with a document the gate would
reject. That call site is load-bearing, not defensive.
- The `SERVICE_KEYS` / `STRUCTURAL_KEYS` split in `keys.py` is a design ruling about which keys can
Expand All @@ -45,7 +45,7 @@ description, and PyPI does not rewrite relative links, so a relative one 404s on

## Code Style

- The core package stays **zero-dependency**; PyYAML is the optional `[yaml]` extra and nothing else
- The core package stays zero-dependency; PyYAML is the optional `[yaml]` extra and nothing else
is added.
- Commit messages: conventional-commit subjects, no `Co-authored-by` trailer.

Expand Down
22 changes: 11 additions & 11 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@ Built for CI and test environments where neither `docker compose` nor

A term is listed only when there is a synonym to reject, or a meaning subtle enough
that code, tests and docs must agree on it. General programming vocabulary does not
belong here, however heavily this package uses it — nor does anything a module
belong here, however heavily this package uses it, and neither does anything a module
docstring already states in its first lines.

**Service-key registry**:
`SERVICE_KEYS` in `keys.py`: the table mapping each *declarative* Compose service key
to its `KeySpec` — the `(validate, emit, merge)` triple saying how that key is
to its `KeySpec`: the `(validate, emit, merge)` triple saying how that key is
checked, how it renders to `podman run` flags, and (for list/map-shaped keys) how it
merges across `extends`. It is the single source both the gate (`validate`) and the
emitter (`run_flags`) derive from, so the two cannot drift apart; that single-sourcing
is the whole point of the table, not an incidental property of it.
emitter (`run_flags`) derive from, so the two cannot drift apart. That single-sourcing
is the reason the table exists.

**Structural key**:
A supported service key handled *outside* the service-key registry, listed in
`STRUCTURAL_KEYS`, because the `emit(value)` interface cannot express it — it needs
`STRUCTURAL_KEYS`, because the `emit(value)` interface cannot express it: it needs
`project_dir` (`env_file`, `volumes`), spans keys, or occupies the image/command slot
(`entrypoint`). Structural keys keep their own validate/emit machinery, in the module
that owns the concern. Which side of this line a key falls on is a design ruling, not
Expand All @@ -32,8 +32,8 @@ a convenience: see
**Token**:
The result of rendering one Compose value into a `podman run`/`pod create` argument:
`Token = str | Expand | GuardedEnvFile` in `keys.py`. A `str` is already shell-safe
and final; the other two members are deferred — they carry something the *generated
script*, not compose2pod, resolves. Anything walking a flag list has to handle all
and final; the other two members are deferred: they carry something that the generated
script resolves, not compose2pod. Anything walking a flag list has to handle all
three.

**Expand**:
Expand All @@ -58,7 +58,7 @@ The two directions of the Docker-rejection parity rule
([ADR-0006](docs/adr/0006-docker-rejection-parity.md)). "Rule two" is named bare
in `parsing.py` comments and in tests, with no restatement at the call site.
**Rule one**: a document `docker compose config` rejects, compose2pod
rejects too — hard, no exceptions. **Rule two**: a document Docker accepts,
rejects too, as a hard rule with no exceptions. **Rule two**: a document Docker accepts,
compose2pod accepts whenever podman can express it. That means every *supported*
podman, from the declared floor to the newest version measured, so a mount option
only a later podman has is refused until the floor reaches it. Where podman cannot
Expand All @@ -75,15 +75,15 @@ here into one pattern. The *name grammar* (`values.TOP_LEVEL_NAME`,
`[a-zA-Z0-9._-]+`) is what a top-level `services`/`volumes`/`secrets`/`configs`
key and a service's long-form `networks` key must match. The *discriminator*
(`parsing._is_named_volume_source`) asks a different thing of a short-form
volume source — named volume, or bind? — and does not use the grammar at all:
volume source (named volume, or bind?) and does not use the grammar at all:
a leading `.`, `/` or `~` is a host path, anything else is a volume name. Say
which one you mean; one pattern doing both jobs is the bug
[ADR-0006](docs/adr/0006-docker-rejection-parity.md) records. Neither covers the
top-level `name`, which Docker does not judge at all — it *rewrites* it and
top-level `name`, which Docker does not judge at all; it rewrites it and
refuses only an empty result (`parsing._validate_project_name`).

**Store**:
The umbrella noun for a Compose `secret` or `config` — the two `StoreKind`s in
The umbrella noun for a Compose `secret` or `config`: the two `StoreKind`s in
`stores.py`. Use *store* for anything true of both, which is nearly everything.
*Secret* is reserved for the podman primitive both kinds render as, and is spoken
only inside `stores.py`; elsewhere it would read as the Compose `secrets:` key and
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)

Convert a Docker Compose file into a POSIX `sh` script that runs its services as a **single Podman pod**.
Convert a Docker Compose file into a POSIX `sh` script that runs its services as a single Podman pod.

Built for CI and test environments where you can't use `docker compose` or `podman kube play`:

Expand Down
12 changes: 6 additions & 6 deletions docs/adr/0003-the-shared-namespace-decides-key-classification.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ Every service runs in one pod sharing `net`, `uts`, `ipc` and `cgroup`, and the
namespace, with localhost discovery through a bind-mounted hosts file, is the reason the tool
exists. Keys that pull a container out of it (`network_mode`, `external_links`) are refused
permanently: `network_mode` moves the container to another namespace, and `external_links` names
a container the generated script never creates, which the pod's hosts file -- where every name
compose2pod writes resolves to `127.0.0.1` -- has no address for, `extra_hosts` being the
a container the generated script never creates. The pod's hosts file, where every name
compose2pod writes resolves to `127.0.0.1`, has no address for it; `extra_hosts` is the
supported way to name one. This clause once also swept up `links` and `expose`, which arrived in
the same sentence and belong in neither category
([#120](https://github.com/modern-python/compose2pod/issues/120), measured against
`docker compose config` v5.1.2). `links` normalises to a `depends_on` edge plus a hostname alias
-- docker refuses `links: [ghost]` exactly as it refuses a ghost `depends_on`, and a self-link as
a cycle -- so it neither escapes the namespace nor is satisfied by it, and ignoring it would drop
`docker compose config` v5.1.2). `links` normalises to a `depends_on` edge plus a hostname alias.
docker refuses `links: [ghost]` exactly as it refuses a ghost `depends_on`, and a self-link as
a cycle, so `links` neither escapes the namespace nor is satisfied by it, and ignoring it would drop
a dependency the `--target` closure is built from. Both halves were mechanisms compose2pod already
had, which is why the tracked limitation was expected to shrink and did
([#132](https://github.com/modern-python/compose2pod/issues/132)): the edge joins the mapping
Expand All @@ -27,6 +27,6 @@ and conflict-checked across the closure onto `podman pod create`, because a cont
the pod owns neither namespace and podman rejects the per-container flag. `stop_signal` and
`stop_grace_period` are accepted but inert for a different reason, in that same table with a
warning: the script tears down with `pod rm -f` and never runs `podman stop`, so the flags would
set metadata nothing consults. Inert is not unchecked -- every key in the table keeps docker's own
set metadata nothing consults. Inert is not unchecked: every key in the table keeps docker's own
shape rule at the gate, since a document carrying a malformed one is a document docker will not
run.
Loading
Loading