Skip to content

Commit 6a5db4d

Browse files
authored
Give keys.py's cross-module primitives a real interface (#49)
* refactor(keys): drop leading underscore on primitives shared across modules * docs(planning): add design rationale for the keys.py primitives rename
1 parent a2744f5 commit 6a5db4d

12 files changed

Lines changed: 269 additions & 121 deletions

File tree

architecture/glossary.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,16 @@ module-private so the store interface (`validate`, `flags`, `create_lines`,
3737
`teardown_line`, `referenced_variables`) hides the kinds from callers — the same
3838
single-source shape as the service-key registry.
3939
_Avoid_: store list, kinds table.
40+
41+
**Token**:
42+
The result of rendering one Compose value into a `podman run`/`pod create`
43+
argument — either a literal `str` (already shell-safe) or an `Expand` (a
44+
value carrying `${VAR}` references that expand at script-run time, not at
45+
generation time). `Token = str | Expand` in `keys.py`.
46+
_Avoid_: arg, flag value.
47+
48+
**Expand**:
49+
A token whose Compose variable references must expand when the generated
50+
script runs, not when compose2pod generates it. In code, `Expand` in
51+
`keys.py`.
52+
_Avoid_: variable, placeholder.

architecture/supported-subset.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,12 @@ compose2pod hoists them onto `podman pod create` instead
207207
- **Value shapes:** `dns` / `dns_search` / `dns_opt` accept a string or a list
208208
of strings; `sysctls` accepts a mapping (`key: value`) or a list of
209209
`"key=value"` strings, each value a string or number. A `${VAR}` inside a
210-
value is wrapped in `_Expand` like other interpolated fields, so it stays
210+
value is wrapped in `Expand` like other interpolated fields, so it stays
211211
live at run time and counts toward `referenced_variables` — the generated
212212
script's own shell expands it when it runs, not compose2pod at generation
213213
time. `--add-host` entries render differently by source: an alias/hostname
214214
entry stays a plain unquoted token (pre-existing behavior, unchanged by
215-
this move), while an `extra_hosts` entry goes through `_Expand` (quoted,
215+
this move), while an `extra_hosts` entry goes through `Expand` (quoted,
216216
`${VAR}`-live) — same as before it was per-service.
217217
- **Pod-wide divergence:** unlike every other service key, these apply to
218218
every container in the pod once emitted — including services that never
@@ -461,18 +461,18 @@ interpolated string leaf into a double-quoted POSIX-shell fragment with the
461461
variable references left live, so the generated script's own shell expands
462462
them against its runtime environment when the script runs.
463463

464-
The interpolated set is exactly what `_Expand(...)` wraps in
464+
The interpolated set is exactly what `Expand(...)` wraps in
465465
`compose2pod/emit.py` and `compose2pod/keys.py` — there is no separate list
466466
to maintain by hand, so treat the **service-key registry**
467467
(`SERVICE_KEYS` in `compose2pod/keys.py`; see `architecture/glossary.md`)
468-
and the `_Expand(...)` call sites in `emit.py` as the source of truth if this
468+
and the `Expand(...)` call sites in `emit.py` as the source of truth if this
469469
enumeration ever appears to drift:
470470

471471
- **Structural fields:** `image` (only when the service has no `build`
472472
override — otherwise the CI image is used, not the compose value),
473473
`command`, `entrypoint`, `environment`, `env_file`, `volumes`, `tmpfs`, and
474474
the healthcheck `test` command.
475-
- **Service-key registry fields** whose spec wraps its value in `_Expand`
475+
- **Service-key registry fields** whose spec wraps its value in `Expand`
476476
e.g. `user`, `working_dir`, `platform`, `group_add`, `cap_add`, `cap_drop`,
477477
`security_opt`, `devices`, `labels`, `annotations`, `ulimits`, and every
478478
numeric resource-limit key (`mem_limit`, `cpus`, `pids_limit`, ...). The

compose2pod/emit.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from compose2pod.exceptions import UnsupportedComposeError
1111
from compose2pod.graph import depends_on, hostnames, startup_order
1212
from compose2pod.healthcheck import health_cmd, interval_seconds
13-
from compose2pod.keys import SERVICE_KEYS, Token, _Expand, _key_value_pairs
13+
from compose2pod.keys import SERVICE_KEYS, Expand, Token, key_value_pairs
1414
from compose2pod.pod import pod_create_flags
1515
from compose2pod.resources import deploy_resource_flags
1616
from compose2pod.shell import to_shell, variable_names
@@ -24,7 +24,7 @@ def image_for(svc: dict[str, Any], ci_image: str) -> Token:
2424
"""Services with a build section run the freshly built CI image."""
2525
if "build" in svc:
2626
return ci_image
27-
return _Expand(value=svc["image"])
27+
return Expand(value=svc["image"])
2828

2929

3030
def command_tokens(svc: dict[str, Any]) -> list[Token]:
@@ -33,8 +33,8 @@ def command_tokens(svc: dict[str, Any]) -> list[Token]:
3333
if command is None:
3434
return []
3535
if isinstance(command, str):
36-
return ["/bin/sh", "-c", _Expand(value=command)]
37-
return [_Expand(value=str(token)) for token in command]
36+
return ["/bin/sh", "-c", Expand(value=command)]
37+
return [Expand(value=str(token)) for token in command]
3838

3939

4040
def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]:
@@ -43,15 +43,15 @@ def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]:
4343
if entrypoint is None:
4444
return []
4545
if isinstance(entrypoint, str):
46-
return ["/bin/sh", "-c", _Expand(value=entrypoint)]
47-
return [_Expand(value=str(token)) for token in entrypoint]
46+
return ["/bin/sh", "-c", Expand(value=entrypoint)]
47+
return [Expand(value=str(token)) for token in entrypoint]
4848

4949

5050
def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None:
5151
"""Add healthcheck flags to the flags list."""
5252
cmd = health_cmd(healthcheck.get("test"))
5353
if cmd is not None:
54-
flags += ["--health-cmd", _Expand(value=cmd)]
54+
flags += ["--health-cmd", Expand(value=cmd)]
5555
if "timeout" in healthcheck:
5656
flags += ["--health-timeout", str(healthcheck["timeout"])]
5757
if "start_period" in healthcheck:
@@ -63,34 +63,34 @@ def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None:
6363
def _add_env_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None:
6464
"""Add -e and --env-file flags to the flags list."""
6565
# A null environment value means "pass KEY through from the host" (bare `-e KEY`).
66-
for pair in _key_value_pairs(svc.get("environment") or {}):
67-
flags += ["-e", _Expand(value=str(pair))]
66+
for pair in key_value_pairs(svc.get("environment") or {}):
67+
flags += ["-e", Expand(value=str(pair))]
6868
env_files = svc.get("env_file") or []
6969
if isinstance(env_files, str):
7070
env_files = [env_files]
7171
for env_file in env_files:
72-
flags += ["--env-file", _Expand(value=str(Path(project_dir, env_file)))]
72+
flags += ["--env-file", Expand(value=str(Path(project_dir, env_file)))]
7373

7474

7575
def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None:
7676
"""Add -v and --tmpfs flags to the flags list."""
7777
for volume in svc.get("volumes") or []:
7878
if ":" not in volume:
7979
# Anonymous volume: a bare container path, no host source to translate.
80-
flags += ["-v", _Expand(value=volume)]
80+
flags += ["-v", Expand(value=volume)]
8181
continue
8282
source, destination = volume.split(":", 1)
8383
if source.startswith("."):
8484
# Relative bind mount: resolve against project_dir.
8585
source = str(Path(project_dir, source))
8686
# Absolute bind mount (starts with "/") and named volume (bare
8787
# identifier) are both kept as-is — neither is a path to translate.
88-
flags += ["-v", _Expand(value=f"{source}:{destination}")]
88+
flags += ["-v", Expand(value=f"{source}:{destination}")]
8989
tmpfs = svc.get("tmpfs") or []
9090
if isinstance(tmpfs, str):
9191
tmpfs = [tmpfs]
9292
for mount in tmpfs:
93-
flags += ["--tmpfs", _Expand(value=mount)]
93+
flags += ["--tmpfs", Expand(value=mount)]
9494

9595

9696
def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> list[Token]:
@@ -168,13 +168,13 @@ class PlannedScript:
168168

169169

170170
def _render(tokens: list[Token]) -> str:
171-
return " ".join(to_shell(token.value) if isinstance(token, _Expand) else shlex.quote(token) for token in tokens)
171+
return " ".join(to_shell(token.value) if isinstance(token, Expand) else shlex.quote(token) for token in tokens)
172172

173173

174174
def _collect_vars(tokens: list[Token], names: set[str]) -> None:
175-
"""Add the run-time variables any `_Expand` tokens expand to `names`."""
175+
"""Add the run-time variables any `Expand` tokens expand to `names`."""
176176
for token in tokens:
177-
if isinstance(token, _Expand):
177+
if isinstance(token, Expand):
178178
names.update(variable_names(token.value))
179179

180180

@@ -220,7 +220,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
220220
221221
Every token source is visited a single time and feeds both outputs: each
222222
service's `_run_tokens` and the `pod_create_flags` render into lines *and*
223-
have their `_Expand` variables collected, so the script and the variable
223+
have their `Expand` variables collected, so the script and the variable
224224
list cannot disagree about what the script expands at run time.
225225
"""
226226
services = compose["services"]

compose2pod/extends.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any
44

55
from compose2pod.exceptions import UnsupportedComposeError
6-
from compose2pod.keys import SERVICE_KEYS, _pairs_to_mapping
6+
from compose2pod.keys import SERVICE_KEYS, pairs_to_mapping
77

88

99
# Merge policy for keys with a SERVICE_KEYS KeySpec comes from spec.merge (see
@@ -38,7 +38,7 @@ def _as_mapping(key: str, name: str, value: Any) -> dict[str, Any]: # noqa: ANN
3838
return value
3939
if isinstance(value, list):
4040
if key == "environment":
41-
return _pairs_to_mapping(name, key, value)
41+
return pairs_to_mapping(name, key, value)
4242
if key == "depends_on":
4343
return {dep: {} for dep in value}
4444
msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"

compose2pod/keys.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@
1616

1717

1818
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
19-
class _Expand:
19+
class Expand:
2020
"""A token whose Compose variable references expand at script-run time."""
2121

2222
value: str
2323

2424

25-
Token = str | _Expand
25+
Token = str | Expand
2626

2727

28-
def _key_value_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
28+
def key_value_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
2929
"""Compose list/map key-value section as 'KEY=value' / 'KEY' entries.
3030
3131
A null map value yields a bare 'KEY'. Meaning is caller-defined: '-e KEY'
@@ -57,12 +57,12 @@ def _validate_bool(name: str, key: str, value: Any) -> None: # noqa: ANN401 - C
5757
raise UnsupportedComposeError(msg)
5858

5959

60-
def _is_number(value: Any) -> bool: # noqa: ANN401 - Compose values are untyped YAML/JSON
60+
def is_number(value: Any) -> bool: # noqa: ANN401 - Compose values are untyped YAML/JSON
6161
return not isinstance(value, bool) and isinstance(value, int | float | str)
6262

6363

6464
def _validate_number(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
65-
if not _is_number(value):
65+
if not is_number(value):
6666
msg = f"service {name!r}: '{key}' must be a number or string"
6767
raise UnsupportedComposeError(msg)
6868

@@ -73,7 +73,7 @@ def _validate_list(name: str, key: str, value: Any) -> None: # noqa: ANN401 - C
7373
raise UnsupportedComposeError(msg)
7474

7575

76-
def _validate_map(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
76+
def validate_map(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
7777
if not isinstance(value, list | dict):
7878
msg = f"service {name!r}: '{key}' must be a list or mapping"
7979
raise UnsupportedComposeError(msg)
@@ -94,8 +94,8 @@ def _concat_list(name: str, key: str, base: Any, local: Any) -> list[Any]: # no
9494
return _as_list(name, key, base) + _as_list(name, key, local)
9595

9696

97-
def _pairs_to_mapping(name: str, key: str, value: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped YAML/JSON
98-
"""Normalize list-or-dict key-value form to a mapping; inverse of _key_value_pairs."""
97+
def pairs_to_mapping(name: str, key: str, value: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped YAML/JSON
98+
"""Normalize list-or-dict key-value form to a mapping; inverse of key_value_pairs."""
9999
if isinstance(value, dict):
100100
return value
101101
if isinstance(value, list):
@@ -110,12 +110,12 @@ def _pairs_to_mapping(name: str, key: str, value: Any) -> dict[str, Any]: # noq
110110

111111
def _merge_map(name: str, key: str, base: Any, local: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped YAML/JSON
112112
"""Merge policy for map-shaped keys: key-by-key merge, local wins."""
113-
return {**_pairs_to_mapping(name, key, base), **_pairs_to_mapping(name, key, local)}
113+
return {**pairs_to_mapping(name, key, base), **pairs_to_mapping(name, key, local)}
114114

115115

116116
def _scalar(flag: str) -> KeySpec:
117117
def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
118-
return [flag, _Expand(value=str(value))]
118+
return [flag, Expand(value=str(value))]
119119

120120
return KeySpec(validate=_validate_scalar, emit=emit)
121121

@@ -129,7 +129,7 @@ def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untype
129129

130130
def _number_scalar(flag: str) -> KeySpec:
131131
def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
132-
return [flag, _Expand(value=str(value))]
132+
return [flag, Expand(value=str(value))]
133133

134134
return KeySpec(validate=_validate_number, emit=emit)
135135

@@ -138,7 +138,7 @@ def _list(flag: str) -> KeySpec:
138138
def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
139139
tokens: list[Token] = []
140140
for item in value:
141-
tokens += [flag, _Expand(value=str(item))]
141+
tokens += [flag, Expand(value=str(item))]
142142
return tokens
143143

144144
return KeySpec(validate=_validate_list, emit=emit, merge=_concat_list)
@@ -147,14 +147,14 @@ def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untype
147147
def _map(flag: str) -> KeySpec:
148148
def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
149149
tokens: list[Token] = []
150-
for pair in _key_value_pairs(value):
151-
tokens += [flag, _Expand(value=str(pair))]
150+
for pair in key_value_pairs(value):
151+
tokens += [flag, Expand(value=str(pair))]
152152
return tokens
153153

154-
return KeySpec(validate=_validate_map, emit=emit, merge=_merge_map)
154+
return KeySpec(validate=validate_map, emit=emit, merge=_merge_map)
155155

156156

157-
def _extra_host_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
157+
def extra_host_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
158158
"""Compose extra_hosts as 'host:ip' entries; map values keep their colons (IPv6-safe)."""
159159
if isinstance(value, list):
160160
return value
@@ -204,7 +204,7 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
204204
return []
205205
tokens: list[Token] = []
206206
for arg in _ulimit_args(value):
207-
tokens += ["--ulimit", _Expand(value=arg)]
207+
tokens += ["--ulimit", Expand(value=arg)]
208208
return tokens
209209

210210

compose2pod/pod.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any
44

55
from compose2pod.exceptions import UnsupportedComposeError
6-
from compose2pod.keys import Token, _Expand, _extra_host_pairs, _validate_map
6+
from compose2pod.keys import Expand, Token, extra_host_pairs, validate_map
77

88

99
_DNS_KEYS = {"dns": "--dns", "dns_search": "--dns-search", "dns_opt": "--dns-option"}
@@ -48,7 +48,7 @@ def validate_pod_options(name: str, svc: dict[str, Any]) -> None:
4848
if "sysctls" in svc:
4949
_sysctl_pairs(name, svc["sysctls"])
5050
if "extra_hosts" in svc:
51-
_validate_map(name, "extra_hosts", svc["extra_hosts"])
51+
validate_map(name, "extra_hosts", svc["extra_hosts"])
5252

5353

5454
def uses_pod_options(services: dict[str, Any]) -> bool:
@@ -66,7 +66,7 @@ def _dns_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
6666
for value in _as_str_list(name, key, svc[key]):
6767
seen[value] = None
6868
for value in seen:
69-
tokens += [flag, _Expand(value=value)]
69+
tokens += [flag, Expand(value=value)]
7070
return tokens
7171

7272

@@ -83,7 +83,7 @@ def _sysctl_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
8383
merged[key] = val
8484
tokens: list[Token] = []
8585
for key, val in merged.items():
86-
tokens += ["--sysctl", _Expand(value=f"{key}={val}")]
86+
tokens += ["--sysctl", Expand(value=f"{key}={val}")]
8787
return tokens
8888

8989

@@ -93,7 +93,7 @@ def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]
9393
A host name landing on two different addresses -- across services' extra_hosts,
9494
or against an alias's fixed 127.0.0.1 -- is refused rather than guessed at, matching
9595
the sysctls conflict rule below. Alias entries render as plain tokens (unquoted, as
96-
before this move); extra_hosts entries render via `_Expand` (as before, quoted/interpolated)
96+
before this move); extra_hosts entries render via `Expand` (as before, quoted/interpolated)
9797
-- relocating the flags changes nothing else observable about either source.
9898
"""
9999
merged: dict[str, str] = {}
@@ -104,7 +104,7 @@ def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]
104104
svc = services[name]
105105
if "extra_hosts" not in svc:
106106
continue
107-
for entry in _extra_host_pairs(svc["extra_hosts"]):
107+
for entry in extra_host_pairs(svc["extra_hosts"]):
108108
host, _sep, addr = str(entry).partition(":")
109109
if merged.get(host, addr) != addr:
110110
msg = f"service {name!r}: conflicting host {host!r} ({merged[host]!r} vs {addr!r})"
@@ -113,7 +113,7 @@ def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]
113113
from_extra_hosts.add(host)
114114
tokens: list[Token] = []
115115
for host, addr in merged.items():
116-
value = _Expand(value=f"{host}:{addr}") if host in from_extra_hosts else f"{host}:{addr}"
116+
value = Expand(value=f"{host}:{addr}") if host in from_extra_hosts else f"{host}:{addr}"
117117
tokens += ["--add-host", value]
118118
return tokens
119119

compose2pod/resources.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
from typing import Any
44

55
from compose2pod.exceptions import UnsupportedComposeError
6-
from compose2pod.keys import Token, _Expand, _is_number
6+
from compose2pod.keys import Expand, Token, is_number
77

88

99
# deploy.resources.limits.<field> -> (podman flag, conflicting legacy key)
1010
_LIMITS = {"cpus": ("--cpus", "cpus"), "memory": ("--memory", "mem_limit"), "pids": ("--pids-limit", "pids_limit")}
1111

1212

1313
def _check_number(name: str, field: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped
14-
if not _is_number(value):
14+
if not is_number(value):
1515
msg = f"service {name!r}: {field} must be a number or string"
1616
raise UnsupportedComposeError(msg)
1717

@@ -91,7 +91,7 @@ def deploy_resource_flags(svc: dict[str, Any]) -> list[Token]:
9191
tokens: list[Token] = []
9292
for field, (flag, _legacy) in _LIMITS.items():
9393
if field in limits:
94-
tokens += [flag, _Expand(value=str(limits[field]))]
94+
tokens += [flag, Expand(value=str(limits[field]))]
9595
if "memory" in reservations:
96-
tokens += ["--memory-reservation", _Expand(value=str(reservations["memory"]))]
96+
tokens += ["--memory-reservation", Expand(value=str(reservations["memory"]))]
9797
return tokens

0 commit comments

Comments
 (0)