compose2pod 0.1.7__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compose2pod
3
- Version: 0.1.7
3
+ Version: 0.2.0
4
4
  Summary: Convert a Docker Compose file into a script that runs its services as a single Podman pod
5
5
  Keywords: podman,docker-compose,compose,pod,ci,testing,containers
6
6
  Author: Artur Shiriev
@@ -24,7 +24,13 @@ Project-URL: Changelog, https://github.com/modern-python/compose2pod/releases
24
24
  Provides-Extra: yaml
25
25
  Description-Content-Type: text/markdown
26
26
 
27
- # compose2pod
27
+ <p align="center">
28
+ <picture>
29
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup-dark.svg">
30
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup-light.svg">
31
+ <img alt="compose2pod" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup.png" width="420">
32
+ </picture>
33
+ </p>
28
34
 
29
35
  [![PyPI version](https://img.shields.io/pypi/v/compose2pod.svg)](https://pypi.org/project/compose2pod/)
30
36
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/compose2pod.svg)](https://pypi.org/project/compose2pod/)
@@ -72,8 +78,9 @@ it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind
72
78
  `volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and
73
79
  network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML
74
80
  anchors are accepted as-is, so a top-level `x-*` anchor block for shared
75
- config just works. `${VAR}`-style variable interpolation is resolved against
76
- the process environment (no `.env` file support). See
81
+ config just works. `${VAR}`-style variable interpolation is left live in the
82
+ generated script, resolved by its shell against the environment present
83
+ when the script runs (no `.env` file support). See
77
84
  `architecture/supported-subset.md` for the full accept/ignore/reject matrix.
78
85
 
79
86
  ## Status
@@ -1,4 +1,10 @@
1
- # compose2pod
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup-light.svg">
5
+ <img alt="compose2pod" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup.png" width="420">
6
+ </picture>
7
+ </p>
2
8
 
3
9
  [![PyPI version](https://img.shields.io/pypi/v/compose2pod.svg)](https://pypi.org/project/compose2pod/)
4
10
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/compose2pod.svg)](https://pypi.org/project/compose2pod/)
@@ -46,8 +52,9 @@ it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind
46
52
  `volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and
47
53
  network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML
48
54
  anchors are accepted as-is, so a top-level `x-*` anchor block for shared
49
- config just works. `${VAR}`-style variable interpolation is resolved against
50
- the process environment (no `.env` file support). See
55
+ config just works. `${VAR}`-style variable interpolation is left live in the
56
+ generated script, resolved by its shell against the environment present
57
+ when the script runs (no `.env` file support). See
51
58
  `architecture/supported-subset.md` for the full accept/ignore/reject matrix.
52
59
 
53
60
  ## Status
@@ -2,14 +2,16 @@
2
2
 
3
3
  from compose2pod.emit import EmitOptions, emit_script
4
4
  from compose2pod.exceptions import UnsupportedComposeError
5
- from compose2pod.interpolate import interpolate
5
+ from compose2pod.extends import resolve_extends
6
6
  from compose2pod.parsing import validate
7
+ from compose2pod.shell import to_shell
7
8
 
8
9
 
9
10
  __all__ = [
10
11
  "EmitOptions",
11
12
  "UnsupportedComposeError",
12
13
  "emit_script",
13
- "interpolate",
14
+ "resolve_extends",
15
+ "to_shell",
14
16
  "validate",
15
17
  ]
@@ -3,16 +3,14 @@
3
3
  import argparse
4
4
  import contextlib
5
5
  import json
6
- import os
7
- import re
8
6
  import sys
9
7
  from pathlib import Path
10
8
  from types import ModuleType
11
9
  from typing import Any
12
10
 
13
- from compose2pod.emit import EmitOptions, emit_script
11
+ from compose2pod.emit import POD_NAME_PATTERN, EmitOptions, emit_script, referenced_variables
14
12
  from compose2pod.exceptions import UnsupportedComposeError
15
- from compose2pod.interpolate import interpolate
13
+ from compose2pod.extends import resolve_extends
16
14
  from compose2pod.parsing import validate
17
15
 
18
16
 
@@ -21,9 +19,6 @@ with contextlib.suppress(ImportError): # the optional [yaml] extra is not insta
21
19
  import yaml as _yaml
22
20
 
23
21
 
24
- POD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
25
-
26
-
27
22
  def _load_yaml(text: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data
28
23
  if _yaml is None:
29
24
  msg = "YAML input requires the 'yaml' extra: pip install compose2pod[yaml] (or pipe JSON via yq)"
@@ -73,7 +68,7 @@ def main(argv: list[str] | None = None) -> int:
73
68
  help="target exit code treated as success in addition to 0",
74
69
  )
75
70
  args = parser.parse_args(argv)
76
- if not POD_NAME_PATTERN.match(args.pod_name):
71
+ if not POD_NAME_PATTERN.fullmatch(args.pod_name):
77
72
  sys.stderr.write(f"compose2pod: error: invalid pod name {args.pod_name!r}\n")
78
73
  return 2
79
74
  if args.file:
@@ -89,24 +84,25 @@ def main(argv: list[str] | None = None) -> int:
89
84
  except (json.JSONDecodeError, UnsupportedComposeError) as error:
90
85
  sys.stderr.write(f"compose2pod: error: could not parse compose input: {error}\n")
91
86
  return 2
87
+ options = EmitOptions(
88
+ target=args.target,
89
+ ci_image=args.image,
90
+ command=args.command,
91
+ pod=args.pod_name,
92
+ project_dir=args.project_dir,
93
+ artifacts=args.artifact,
94
+ allow_exit_codes=args.allow_exit_code,
95
+ )
92
96
  try:
93
- compose, interpolation_warnings = interpolate(compose, os.environ)
94
- warnings = [*interpolation_warnings, *validate(compose)]
95
- script = emit_script(
96
- compose=compose,
97
- options=EmitOptions(
98
- target=args.target,
99
- ci_image=args.image,
100
- command=args.command,
101
- pod=args.pod_name,
102
- project_dir=args.project_dir,
103
- artifacts=args.artifact,
104
- allow_exit_codes=args.allow_exit_code,
105
- ),
106
- )
97
+ compose = resolve_extends(compose)
98
+ warnings = validate(compose)
99
+ script = emit_script(compose=compose, options=options)
107
100
  except UnsupportedComposeError as error:
108
101
  sys.stderr.write(f"compose2pod: error: {error}\n")
109
102
  return 2
103
+ referenced = referenced_variables(compose, options)
104
+ if referenced:
105
+ sys.stderr.write("compose2pod: note: script references variables at run time: " + ", ".join(referenced) + "\n")
110
106
  for warning in warnings:
111
107
  sys.stderr.write(f"compose2pod: {warning}\n")
112
108
  sys.stdout.write(script)
@@ -0,0 +1,274 @@
1
+ """Render the podman-pod test script for a target service and its dependencies."""
2
+
3
+ import dataclasses
4
+ import re
5
+ import shlex
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from compose2pod import stores
10
+ from compose2pod.exceptions import UnsupportedComposeError
11
+ from compose2pod.graph import depends_on, hostnames, startup_order
12
+ from compose2pod.healthcheck import health_cmd, interval_seconds
13
+ from compose2pod.keys import SERVICE_KEYS, Token, _Expand, _key_value_pairs
14
+ from compose2pod.pod import pod_create_flags
15
+ from compose2pod.resources import deploy_resource_flags
16
+ from compose2pod.shell import to_shell, variable_names
17
+
18
+
19
+ HEALTHY_WAIT_BUDGET_SECONDS = 120
20
+ POD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
21
+
22
+
23
+ def image_for(svc: dict[str, Any], ci_image: str) -> Token:
24
+ """Services with a build section run the freshly built CI image."""
25
+ if "build" in svc:
26
+ return ci_image
27
+ return _Expand(value=svc["image"])
28
+
29
+
30
+ def command_tokens(svc: dict[str, Any]) -> list[Token]:
31
+ """Service command as argv tokens; compose string form means shell form."""
32
+ command = svc.get("command")
33
+ if command is None:
34
+ return []
35
+ if isinstance(command, str):
36
+ return ["/bin/sh", "-c", _Expand(value=command)]
37
+ return [_Expand(value=str(token)) for token in command]
38
+
39
+
40
+ def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]:
41
+ """Service entrypoint as argv tokens; compose string form means shell form."""
42
+ entrypoint = svc.get("entrypoint")
43
+ if entrypoint is None:
44
+ return []
45
+ if isinstance(entrypoint, str):
46
+ return ["/bin/sh", "-c", _Expand(value=entrypoint)]
47
+ return [_Expand(value=str(token)) for token in entrypoint]
48
+
49
+
50
+ def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None:
51
+ """Add healthcheck flags to the flags list."""
52
+ cmd = health_cmd(healthcheck.get("test"))
53
+ if cmd is not None:
54
+ flags += ["--health-cmd", _Expand(value=cmd)]
55
+ if "timeout" in healthcheck:
56
+ flags += ["--health-timeout", str(healthcheck["timeout"])]
57
+ if "start_period" in healthcheck:
58
+ flags += ["--health-start-period", str(healthcheck["start_period"])]
59
+ if "retries" in healthcheck:
60
+ flags += ["--health-retries", str(healthcheck["retries"])]
61
+
62
+
63
+ def _add_env_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None:
64
+ """Add -e and --env-file flags to the flags list."""
65
+ # 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))]
68
+ env_files = svc.get("env_file") or []
69
+ if isinstance(env_files, str):
70
+ env_files = [env_files]
71
+ for env_file in env_files:
72
+ flags += ["--env-file", _Expand(value=str(Path(project_dir, env_file)))]
73
+
74
+
75
+ def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None:
76
+ """Add -v and --tmpfs flags to the flags list."""
77
+ for volume in svc.get("volumes") or []:
78
+ if ":" not in volume:
79
+ # Anonymous volume: a bare container path, no host source to translate.
80
+ flags += ["-v", _Expand(value=volume)]
81
+ continue
82
+ source, destination = volume.split(":", 1)
83
+ if source.startswith("."):
84
+ # Relative bind mount: resolve against project_dir.
85
+ source = str(Path(project_dir, source))
86
+ # Absolute bind mount (starts with "/") and named volume (bare
87
+ # identifier) are both kept as-is — neither is a path to translate.
88
+ flags += ["-v", _Expand(value=f"{source}:{destination}")]
89
+ tmpfs = svc.get("tmpfs") or []
90
+ if isinstance(tmpfs, str):
91
+ tmpfs = [tmpfs]
92
+ for mount in tmpfs:
93
+ flags += ["--tmpfs", _Expand(value=mount)]
94
+
95
+
96
+ def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> list[Token]:
97
+ """Flag tokens (unquoted) for `podman run` of one service."""
98
+ flags: list[Token] = ["--pod", pod, "--name", f"{pod}-{name}"]
99
+ _add_env_flags(flags, svc, project_dir)
100
+ _add_volume_flags(flags, svc, project_dir)
101
+ _add_health_flags(flags, svc.get("healthcheck") or {})
102
+ for key, spec in SERVICE_KEYS.items():
103
+ if key in svc:
104
+ flags += spec.emit(svc[key])
105
+ flags += stores.flags(svc, pod)
106
+ flags += deploy_resource_flags(svc)
107
+ return flags
108
+
109
+
110
+ _SCRIPT_HEADER = """\
111
+ #!/bin/sh
112
+ # Generated by compose2pod -- do not edit, regenerate instead.
113
+ set -eu
114
+
115
+ wait_healthy() {
116
+ ctr=$1
117
+ attempts=$2
118
+ interval=$3
119
+ i=0
120
+ while [ "$i" -lt "$attempts" ]; do
121
+ if podman healthcheck run "$ctr"; then
122
+ return 0
123
+ fi
124
+ i=$((i + 1))
125
+ sleep "$interval"
126
+ done
127
+ echo "wait_healthy: $ctr did not become healthy after $attempts attempts" >&2
128
+ podman logs "$ctr" >&2 || true
129
+ return 1
130
+ }
131
+ """
132
+
133
+
134
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
135
+ class EmitOptions:
136
+ """Options for emit_script rendering."""
137
+
138
+ target: str
139
+ ci_image: str
140
+ command: str
141
+ pod: str
142
+ project_dir: str
143
+ artifacts: list[str]
144
+ allow_exit_codes: list[int]
145
+
146
+
147
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
148
+ class PlannedScript:
149
+ """The rendered pod script and the run-time variables it expands.
150
+
151
+ Both are products of a single `_plan` traversal, so they cannot drift.
152
+ """
153
+
154
+ script: str
155
+ variables: list[str]
156
+
157
+
158
+ def _render(tokens: list[Token]) -> str:
159
+ return " ".join(to_shell(token.value) if isinstance(token, _Expand) else shlex.quote(token) for token in tokens)
160
+
161
+
162
+ def _collect_vars(tokens: list[Token], names: set[str]) -> None:
163
+ """Add the run-time variables any `_Expand` tokens expand to `names`."""
164
+ for token in tokens:
165
+ if isinstance(token, _Expand):
166
+ names.update(variable_names(token.value))
167
+
168
+
169
+ def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions) -> list[Token]:
170
+ svc = services[name]
171
+ tokens = run_flags(name, svc, options.pod, options.project_dir)
172
+ entrypoint = entrypoint_tokens(svc)
173
+ if entrypoint:
174
+ tokens += ["--entrypoint", entrypoint[0]]
175
+ tokens.append(image_for(svc, options.ci_image))
176
+ tokens += entrypoint[1:]
177
+ if name == options.target and options.command:
178
+ tokens.extend(shlex.split(options.command))
179
+ elif not isinstance(svc.get("entrypoint"), str):
180
+ # A string (shell-form) entrypoint runs via `sh -c`; the service command
181
+ # is ignored, matching Docker. List/exec form appends the command.
182
+ tokens.extend(command_tokens(svc))
183
+ return tokens
184
+
185
+
186
+ def _emit_target(lines: list[str], run_tokens: list[Token], options: EmitOptions) -> None:
187
+ target_ctr = shlex.quote(f"{options.pod}-{options.target}")
188
+ lines.append("rc=0")
189
+ lines.append(f"podman run {_render(run_tokens)} || rc=$?")
190
+ for artifact in options.artifacts:
191
+ source, destination = artifact.split(":", 1)
192
+ lines.append(f"podman cp {target_ctr}:{shlex.quote(source)} {shlex.quote(destination)} || true")
193
+ allowed = "|".join(str(code) for code in [0, *options.allow_exit_codes])
194
+ lines.append('case "$rc" in')
195
+ lines.append(f" {allowed}) ;;")
196
+ lines.append(' *) echo "target service failed with exit code $rc" >&2')
197
+ lines.append(
198
+ " podman inspect --format "
199
+ "'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' " + target_ctr + " >&2 || true"
200
+ )
201
+ lines.append(" podman ps -a --format '{{.Names}} {{.Status}}' >&2 || true")
202
+ lines.append(' exit "$rc" ;;')
203
+ lines.append("esac")
204
+
205
+
206
+ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
207
+ """Walk the target's dependency closure once, building the script and its variables.
208
+
209
+ Every token source is visited a single time and feeds both outputs: each
210
+ service's `_run_tokens` and the `pod_create_flags` render into lines *and*
211
+ have their `_Expand` variables collected, so the script and the variable
212
+ list cannot disagree about what the script expands at run time.
213
+ """
214
+ services = compose["services"]
215
+ hosts = hostnames(services)
216
+ order = startup_order(services, options.target)
217
+ completion_gated = {
218
+ dep
219
+ for svc in services.values()
220
+ for dep, condition in depends_on(svc).items()
221
+ if condition == "service_completed_successfully"
222
+ }
223
+ names: set[str] = set()
224
+
225
+ lines = [_SCRIPT_HEADER]
226
+ teardown = f"podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true"
227
+ store_teardown = stores.teardown_line(compose, order, options.pod)
228
+ if store_teardown:
229
+ teardown += f"; {store_teardown}"
230
+ lines.append(f"trap '{teardown}' EXIT")
231
+ pod_flags = pod_create_flags(services, order, hosts)
232
+ _collect_vars(pod_flags, names)
233
+ pod_create = f"podman pod create --name {shlex.quote(options.pod)}"
234
+ if pod_flags:
235
+ pod_create += " " + _render(pod_flags)
236
+ lines.append(pod_create)
237
+ lines.extend(stores.create_lines(compose, order, options.pod, options.project_dir))
238
+ names |= stores.referenced_variables(compose, order, options.project_dir)
239
+ waited: set[str] = set()
240
+ for name in order:
241
+ for dep, condition in depends_on(services[name]).items():
242
+ if condition == "service_healthy" and dep not in waited:
243
+ interval = interval_seconds((services[dep].get("healthcheck") or {}).get("interval"))
244
+ attempts = max(HEALTHY_WAIT_BUDGET_SECONDS // interval, 1)
245
+ lines.append(f"wait_healthy {shlex.quote(f'{options.pod}-{dep}')} {attempts} {interval}")
246
+ waited.add(dep)
247
+ run_tokens = _run_tokens(name, services, options)
248
+ _collect_vars(run_tokens, names)
249
+ if name == options.target:
250
+ _emit_target(lines, run_tokens, options)
251
+ elif name in completion_gated:
252
+ lines.append(f"podman run --rm {_render(run_tokens)}")
253
+ else:
254
+ lines.append(f"podman run -d {_render(run_tokens)}")
255
+ return PlannedScript(script="\n".join(lines) + "\n", variables=sorted(names))
256
+
257
+
258
+ def emit_script(compose: dict[str, Any], options: EmitOptions) -> str:
259
+ """Render the full pod test script for `target` and its dependency closure."""
260
+ if not POD_NAME_PATTERN.fullmatch(options.pod):
261
+ msg = f"invalid pod name {options.pod!r}"
262
+ raise UnsupportedComposeError(msg)
263
+ return _plan(compose, options).script
264
+
265
+
266
+ def referenced_variables(compose: dict[str, Any], options: EmitOptions) -> list[str]:
267
+ """Variable names the emitted script expands at run time (sorted, unique).
268
+
269
+ Projects the same `_plan` traversal `emit_script` renders from, so it counts
270
+ exactly the values that pass through `to_shell` -- never a `${VAR}` in a
271
+ field emitted literally, and never the `--command` override (a literal CLI
272
+ value).
273
+ """
274
+ return _plan(compose, options).variables
@@ -0,0 +1,127 @@
1
+ """Resolve same-file compose `extends`: flatten each service's inheritance."""
2
+
3
+ from typing import Any
4
+
5
+ from compose2pod.exceptions import UnsupportedComposeError
6
+
7
+
8
+ _MERGE_KEYS = {"environment", "labels", "annotations", "extra_hosts", "ulimits", "healthcheck", "depends_on"}
9
+ _CONCAT_KEYS = {
10
+ "cap_add",
11
+ "cap_drop",
12
+ "security_opt",
13
+ "devices",
14
+ "group_add",
15
+ "secrets",
16
+ "configs",
17
+ "volumes",
18
+ "tmpfs",
19
+ "env_file",
20
+ }
21
+
22
+
23
+ def _extends_target(name: str, ext: Any) -> str: # noqa: ANN401 - Compose values are untyped
24
+ """Return the referenced service name, after refusing cross-file and malformed forms."""
25
+ if not isinstance(ext, dict):
26
+ msg = f"service {name!r}: 'extends' must be a mapping with a 'service' key"
27
+ raise UnsupportedComposeError(msg)
28
+ if "file" in ext:
29
+ msg = f"service {name!r}: extends with 'file:' (cross-file) is not supported"
30
+ raise UnsupportedComposeError(msg)
31
+ unknown = set(ext) - {"service"}
32
+ if unknown:
33
+ msg = f"service {name!r}: unsupported 'extends' keys {sorted(unknown)}"
34
+ raise UnsupportedComposeError(msg)
35
+ service = ext.get("service")
36
+ if not isinstance(service, str):
37
+ msg = f"service {name!r}: extends 'service' must be a string"
38
+ raise UnsupportedComposeError(msg)
39
+ return service
40
+
41
+
42
+ def _env_list_to_map(items: list[Any]) -> dict[str, Any]:
43
+ """Normalize a `[KEY=value, BARE]` environment list to a mapping (bare -> None)."""
44
+ result: dict[str, Any] = {}
45
+ for item in items:
46
+ key, sep, value = str(item).partition("=")
47
+ result[key] = value if sep else None
48
+ return result
49
+
50
+
51
+ def _as_mapping(key: str, name: str, value: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped
52
+ if isinstance(value, dict):
53
+ return value
54
+ if isinstance(value, list):
55
+ if key == "environment":
56
+ return _env_list_to_map(value)
57
+ if key == "depends_on":
58
+ return {dep: {} for dep in value}
59
+ msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"
60
+ raise UnsupportedComposeError(msg)
61
+
62
+
63
+ def _as_list(key: str, name: str, value: Any) -> list[Any]: # noqa: ANN401 - Compose values are untyped
64
+ if isinstance(value, list):
65
+ return list(value)
66
+ if isinstance(value, str):
67
+ return [value]
68
+ msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"
69
+ raise UnsupportedComposeError(msg)
70
+
71
+
72
+ def _merge(base: dict[str, Any], local: dict[str, Any], name: str) -> dict[str, Any]:
73
+ """Merge `local` onto `base` per key category: mapping-merge, sequence-concat, else override."""
74
+ merged: dict[str, Any] = dict(base)
75
+ for key, local_val in local.items():
76
+ if key in base and key in _MERGE_KEYS:
77
+ merged[key] = {**_as_mapping(key, name, base[key]), **_as_mapping(key, name, local_val)}
78
+ elif key in base and key in _CONCAT_KEYS:
79
+ merged[key] = _as_list(key, name, base[key]) + _as_list(key, name, local_val)
80
+ else:
81
+ merged[key] = local_val
82
+ return merged
83
+
84
+
85
+ def resolve_extends(compose: Any) -> Any: # noqa: ANN401 - Compose values are untyped
86
+ """Return a new document with every service's same-file `extends` flattened.
87
+
88
+ Transitive and cycle-checked; cross-file (`file:`) extends is refused. A
89
+ non-dict document or non-dict `services` is returned unchanged for
90
+ `validate()` to reject.
91
+ """
92
+ if not isinstance(compose, dict):
93
+ return compose
94
+ services = compose.get("services")
95
+ if not isinstance(services, dict):
96
+ return compose
97
+ resolved: dict[str, Any] = {}
98
+ resolving: set[str] = set()
99
+
100
+ def resolve(name: str) -> Any: # noqa: ANN401 - Compose values are untyped
101
+ if name in resolved:
102
+ return resolved[name]
103
+ svc = services[name]
104
+ ext = svc.get("extends") if isinstance(svc, dict) else None
105
+ if ext is None:
106
+ resolved[name] = svc
107
+ return svc
108
+ if name in resolving:
109
+ msg = f"extends cycle involving {name!r}"
110
+ raise UnsupportedComposeError(msg)
111
+ resolving.add(name)
112
+ base_name = _extends_target(name, ext)
113
+ if base_name not in services:
114
+ msg = f"service {name!r}: extends unknown service {base_name!r}"
115
+ raise UnsupportedComposeError(msg)
116
+ base = resolve(base_name)
117
+ local = {key: value for key, value in svc.items() if key != "extends"}
118
+ resolving.discard(name)
119
+ if not isinstance(base, dict):
120
+ resolved[name] = local
121
+ return local
122
+ merged = _merge(base, local, name)
123
+ resolved[name] = merged
124
+ return merged
125
+
126
+ new_services = {name: resolve(name) for name in services}
127
+ return {**compose, "services": new_services}
@@ -10,24 +10,44 @@ def depends_on(svc: dict[str, Any]) -> dict[str, str]:
10
10
  deps = svc.get("depends_on") or {}
11
11
  if isinstance(deps, list):
12
12
  return cast(dict[str, str], dict.fromkeys(deps, "service_started"))
13
- return {name: spec.get("condition", "service_started") for name, spec in deps.items()}
13
+ if not isinstance(deps, dict):
14
+ msg = "'depends_on' must be a list or mapping"
15
+ raise UnsupportedComposeError(msg)
16
+ result: dict[str, str] = {}
17
+ for dep, spec in deps.items():
18
+ if not isinstance(spec, dict):
19
+ msg = f"depends_on entry {dep!r} must be a mapping"
20
+ raise UnsupportedComposeError(msg)
21
+ result[dep] = spec.get("condition", "service_started")
22
+ return result
23
+
24
+
25
+ def _host_names(name: str, svc: dict[str, Any]) -> list[str]:
26
+ """Names one service is reachable by: hostname, container_name, and network aliases."""
27
+ result: list[str] = []
28
+ for key in ("hostname", "container_name"):
29
+ value = svc.get(key)
30
+ if value is not None and not isinstance(value, str):
31
+ msg = f"service {name!r}: {key} must be a string"
32
+ raise UnsupportedComposeError(msg)
33
+ if value:
34
+ result.append(value)
35
+ networks = svc.get("networks")
36
+ if networks is not None and not isinstance(networks, list | dict):
37
+ msg = f"service {name!r}: networks must be a list or mapping"
38
+ raise UnsupportedComposeError(msg)
39
+ if isinstance(networks, dict):
40
+ for network in networks.values():
41
+ if isinstance(network, dict):
42
+ result.extend(network.get("aliases") or [])
43
+ return result
14
44
 
15
45
 
16
46
  def hostnames(services: dict[str, Any]) -> list[str]:
17
47
  """All names other services may use to reach a service: names, hostnames/container names, then aliases."""
18
48
  names = list(services)
19
- for svc in services.values():
20
- hostname = svc.get("hostname")
21
- if hostname:
22
- names.append(hostname)
23
- container_name = svc.get("container_name")
24
- if container_name:
25
- names.append(container_name)
26
- networks = svc.get("networks")
27
- if isinstance(networks, dict):
28
- for network in networks.values():
29
- if isinstance(network, dict):
30
- names.extend(network.get("aliases") or [])
49
+ for name, svc in services.items():
50
+ names.extend(_host_names(name, svc))
31
51
  return names
32
52
 
33
53
 
@@ -1,6 +1,7 @@
1
1
  """Healthcheck translation: compose healthcheck -> podman --health-* values."""
2
2
 
3
3
  import json
4
+ import math
4
5
  from typing import Any
5
6
 
6
7
  from compose2pod.exceptions import UnsupportedComposeError
@@ -43,12 +44,16 @@ def interval_seconds(duration: object) -> int:
43
44
  """Compose duration ('1s', '2m', '500ms', int) to whole seconds, minimum 1."""
44
45
  if duration is None:
45
46
  return 1
46
- if isinstance(duration, (int, float)):
47
+ if isinstance(duration, (int, float)) and math.isfinite(duration):
47
48
  return max(int(duration), 1)
49
+ # Non-finite floats (inf/nan) fall through to the guarded parse below and are refused.
48
50
  text = str(duration).strip()
49
- if text.endswith("ms"):
50
- return max(int(float(text[:-2]) / 1000), 1)
51
- if text.endswith("m"):
52
- return max(int(float(text[:-1])) * 60, 1)
53
- text = text.removesuffix("s")
54
- return max(int(float(text)), 1)
51
+ try:
52
+ if text.endswith("ms"):
53
+ return max(int(float(text[:-2]) / 1000), 1)
54
+ if text.endswith("m"):
55
+ return max(int(float(text[:-1])) * 60, 1)
56
+ return max(int(float(text.removesuffix("s"))), 1)
57
+ except (ValueError, OverflowError):
58
+ msg = f"unsupported healthcheck interval {duration!r} (use forms like '30s', '2m', '500ms')"
59
+ raise UnsupportedComposeError(msg) from None