diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 7e9e7cc..1fde0be 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -126,6 +126,54 @@ warns (ignored, behavior-neutral inside a single pod) or raises silently. - Everything else raises. +## Extends + +- **Resolution timing:** `extends` is resolved by `resolve_extends` + (`compose2pod/extends.py`) as a pre-validation flattening step, called from + `cli.py` immediately before `validate()` — the rest of the pipeline + (`validate()`, `emit_script()`) never sees an `extends` key. +- **Supported form:** only the mapping form `extends: {service: }`; + `service` must name another service in the same document. Resolution is + transitive (a chain of `extends` is fully flattened) and cycle-checked — an + `extends` cycle raises `UnsupportedComposeError`. +- **Merge rules**, applied per key when both the base service and the local + (extending) service define it: + - **Mapping-merge, local wins:** `environment`, `labels`, `annotations`, + `extra_hosts`, `ulimits`, `healthcheck`, `depends_on` — base's and local's + keys are combined into one mapping, with local's value winning on + collision. + - **Sequence-concatenate, base then local:** `cap_add`, `cap_drop`, + `security_opt`, `devices`, `group_add`, `secrets`, `configs`, `volumes`, + `tmpfs`, `env_file` — base's list is followed by local's list, unchanged. + - **Override, local replaces:** every other key, including `command` and + `entrypoint` (argv replaced wholesale, never concatenated) — this also + covers unknown keys, which `validate()` then rejects downstream exactly + as it would without `extends`. + - **Normalization before merge:** list-form `environment` + (`- KEY=value` / `- KEY`) and list-form `depends_on` (a bare + service-name list) are normalized to mappings before the mapping-merge; + scalar-form `tmpfs`/`env_file` (a single string) are normalized to a + one-element list before the concatenation. +- **Refused loudly** (`UnsupportedComposeError`), rather than guessed at: + cross-file `extends: {file: ..., service: ...}`; a bare-string `extends` + (or any other non-mapping value); an unrecognized key under `extends` + other than `service`; a non-string `service`; an `extends` `service` + naming a service that doesn't exist in the document; and a merge across + incompatible forms — a mapping-merge key that is neither a mapping nor a + list-form `environment`/`depends_on`, or a sequence-concatenate key that is + neither a list nor a scalar string. +- **Divergences from Compose:** + - Only `environment` and `depends_on` accept list form for the + mapping-merge; the other mapping-merge keys (`labels`, `annotations`, + `extra_hosts`, `ulimits`, `healthcheck`) in list form on a merged side are + refused as an incompatible form rather than silently coerced. + - Short-form `volumes` are concatenated rather than merged by target path; + podman resolves duplicate mounts at run time rather than compose2pod + deduplicating them at generation time. + - Referenced resources (top-level `volumes`, `networks`, `secrets`, + `configs`) are not auto-imported by `extends` — as in Compose, the + extending service must declare what it needs. + ## Healthcheck keys - **Supported:** `test`, `interval`, `timeout`, `retries`, `start_period`. diff --git a/compose2pod/__init__.py b/compose2pod/__init__.py index a7cd8e1..4ff39fa 100644 --- a/compose2pod/__init__.py +++ b/compose2pod/__init__.py @@ -2,6 +2,7 @@ from compose2pod.emit import EmitOptions, emit_script from compose2pod.exceptions import UnsupportedComposeError +from compose2pod.extends import resolve_extends from compose2pod.parsing import validate from compose2pod.shell import to_shell @@ -10,6 +11,7 @@ "EmitOptions", "UnsupportedComposeError", "emit_script", + "resolve_extends", "to_shell", "validate", ] diff --git a/compose2pod/cli.py b/compose2pod/cli.py index fa80343..73acdc4 100644 --- a/compose2pod/cli.py +++ b/compose2pod/cli.py @@ -11,6 +11,7 @@ from compose2pod.emit import EmitOptions, emit_script, referenced_variables from compose2pod.exceptions import UnsupportedComposeError +from compose2pod.extends import resolve_extends from compose2pod.parsing import validate @@ -97,6 +98,7 @@ def main(argv: list[str] | None = None) -> int: allow_exit_codes=args.allow_exit_code, ) try: + compose = resolve_extends(compose) warnings = validate(compose) script = emit_script(compose=compose, options=options) except UnsupportedComposeError as error: diff --git a/compose2pod/extends.py b/compose2pod/extends.py new file mode 100644 index 0000000..198e0f2 --- /dev/null +++ b/compose2pod/extends.py @@ -0,0 +1,127 @@ +"""Resolve same-file compose `extends`: flatten each service's inheritance.""" + +from typing import Any + +from compose2pod.exceptions import UnsupportedComposeError + + +_MERGE_KEYS = {"environment", "labels", "annotations", "extra_hosts", "ulimits", "healthcheck", "depends_on"} +_CONCAT_KEYS = { + "cap_add", + "cap_drop", + "security_opt", + "devices", + "group_add", + "secrets", + "configs", + "volumes", + "tmpfs", + "env_file", +} + + +def _extends_target(name: str, ext: Any) -> str: # noqa: ANN401 - Compose values are untyped + """Return the referenced service name, after refusing cross-file and malformed forms.""" + if not isinstance(ext, dict): + msg = f"service {name!r}: 'extends' must be a mapping with a 'service' key" + raise UnsupportedComposeError(msg) + if "file" in ext: + msg = f"service {name!r}: extends with 'file:' (cross-file) is not supported" + raise UnsupportedComposeError(msg) + unknown = set(ext) - {"service"} + if unknown: + msg = f"service {name!r}: unsupported 'extends' keys {sorted(unknown)}" + raise UnsupportedComposeError(msg) + service = ext.get("service") + if not isinstance(service, str): + msg = f"service {name!r}: extends 'service' must be a string" + raise UnsupportedComposeError(msg) + return service + + +def _env_list_to_map(items: list[Any]) -> dict[str, Any]: + """Normalize a `[KEY=value, BARE]` environment list to a mapping (bare -> None).""" + result: dict[str, Any] = {} + for item in items: + key, sep, value = str(item).partition("=") + result[key] = value if sep else None + return result + + +def _as_mapping(key: str, name: str, value: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped + if isinstance(value, dict): + return value + if isinstance(value, list): + if key == "environment": + return _env_list_to_map(value) + if key == "depends_on": + return {dep: {} for dep in value} + msg = f"service {name!r}: cannot merge {key!r} across incompatible forms" + raise UnsupportedComposeError(msg) + + +def _as_list(key: str, name: str, value: Any) -> list[Any]: # noqa: ANN401 - Compose values are untyped + if isinstance(value, list): + return list(value) + if isinstance(value, str): + return [value] + msg = f"service {name!r}: cannot merge {key!r} across incompatible forms" + raise UnsupportedComposeError(msg) + + +def _merge(base: dict[str, Any], local: dict[str, Any], name: str) -> dict[str, Any]: + """Merge `local` onto `base` per key category: mapping-merge, sequence-concat, else override.""" + merged: dict[str, Any] = dict(base) + for key, local_val in local.items(): + if key in base and key in _MERGE_KEYS: + merged[key] = {**_as_mapping(key, name, base[key]), **_as_mapping(key, name, local_val)} + elif key in base and key in _CONCAT_KEYS: + merged[key] = _as_list(key, name, base[key]) + _as_list(key, name, local_val) + else: + merged[key] = local_val + return merged + + +def resolve_extends(compose: Any) -> Any: # noqa: ANN401 - Compose values are untyped + """Return a new document with every service's same-file `extends` flattened. + + Transitive and cycle-checked; cross-file (`file:`) extends is refused. A + non-dict document or non-dict `services` is returned unchanged for + `validate()` to reject. + """ + if not isinstance(compose, dict): + return compose + services = compose.get("services") + if not isinstance(services, dict): + return compose + resolved: dict[str, Any] = {} + resolving: set[str] = set() + + def resolve(name: str) -> Any: # noqa: ANN401 - Compose values are untyped + if name in resolved: + return resolved[name] + svc = services[name] + ext = svc.get("extends") if isinstance(svc, dict) else None + if ext is None: + resolved[name] = svc + return svc + if name in resolving: + msg = f"extends cycle involving {name!r}" + raise UnsupportedComposeError(msg) + resolving.add(name) + base_name = _extends_target(name, ext) + if base_name not in services: + msg = f"service {name!r}: extends unknown service {base_name!r}" + raise UnsupportedComposeError(msg) + base = resolve(base_name) + local = {key: value for key, value in svc.items() if key != "extends"} + resolving.discard(name) + if not isinstance(base, dict): + resolved[name] = local + return local + merged = _merge(base, local, name) + resolved[name] = merged + return merged + + new_services = {name: resolve(name) for name in services} + return {**compose, "services": new_services} diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 06ac705..50128e8 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -67,8 +67,11 @@ def _validate_tmpfs(name: str, svc: dict[str, Any]) -> None: raise UnsupportedComposeError(msg) -def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: +def _validate_service(name: str, svc: Any) -> list[str]: # noqa: ANN401 - Compose values are untyped """Validate one service; returns warnings, raises UnsupportedComposeError.""" + if not isinstance(svc, dict): + msg = f"service {name!r} must be a mapping" + raise UnsupportedComposeError(msg) warnings: list[str] = [] for key in sorted(svc): if key.startswith("x-"): diff --git a/planning/changes/2026-07-11.01-extends.md b/planning/changes/2026-07-11.01-extends.md new file mode 100644 index 0000000..91691b3 --- /dev/null +++ b/planning/changes/2026-07-11.01-extends.md @@ -0,0 +1,112 @@ +--- +summary: Resolve same-file compose `extends` in a new pure `extends.py` module that flattens each service's inheritance (transitive, cycle-checked, per-key merge: override/merge/concat) before validation; cross-file `file:` extends is refused loudly to keep the single-document input model. +--- + +# Design: compose extends + +## Summary + +Support the common form of compose `extends` — a service inheriting another +service's config within the same document. A new pure module +`compose2pod/extends.py` exposes `resolve_extends(compose) -> compose` that +flattens every service's `extends` away (transitive, cycle-checked, per-key +merge) before `validate()` runs, so the rest of the pipeline never sees +`extends`. A cross-file `file:` extends is refused loudly, keeping compose2pod's +single-document input model intact. + +## Motivation + +`extends` is a widely used authoring convenience: a base service holds shared +config and derived services override or add a few keys. Today `extends` is an +unsupported service key, so any document using it is rejected outright. The +prior coverage audit (`audits/2026-07-09-compose-spec-coverage.md`) places +`extends` in Bucket B (build) and flags its one hard part — the multi-file +`file:` model — as "the largest design here." Resolving `extends` natively +(rather than telling users to pre-flatten with `docker compose config`) keeps +compose2pod self-contained and preserves its deferred `${VAR}` model, which +`docker compose config` would otherwise resolve at config time. + +## Design + +**Module.** `compose2pod/extends.py`, a pure function +`resolve_extends(compose: dict[str, Any]) -> dict[str, Any]` returning a new +document with every service's `extends` flattened. Same-file only, so no +filesystem access and no `--project-dir` dependency. Import direction: `cli` -> +`extends`; `extends` -> `exceptions` only. + +**Pipeline.** `cli.py` calls it between reading and validating: + +```python +compose = _read_compose(text, args.format) +compose = resolve_extends(compose) # flatten inheritance +warnings = validate(compose) # never sees `extends` +script = emit_script(compose=compose, options=options) +``` + +A library caller that skips `resolve_extends` still gets a clean rejection: +`validate()` treats a leftover `extends` as an unsupported service key. + +**Resolution.** For each service carrying `extends`: + +- The value must be the mapping form `{service: , file?: ...}`. A `file:` + key is refused (`extends with 'file:' (cross-file) is not supported`); a + bare-string form is refused (not valid in the current spec); unknown keys + under `extends` are refused. +- `service:` must name a service in the same document; unknown -> refuse. +- Resolution is **transitive** (resolve the base first), and a **cycle** raises + `UnsupportedComposeError` (`extends cycle: a -> b -> a`). +- The flattened service is `merge(resolved_base, local)` with `extends` + stripped. + +**Merge semantics** (per-key category, matching the spec for compose2pod's +supported subset): + +- **CONCAT** (base then local): `cap_add`, `cap_drop`, `security_opt`, + `devices`, `group_add`, `secrets`, `configs`, `volumes`, `tmpfs`, `env_file`. + A scalar form (e.g. `tmpfs: /run`) is normalized to a one-element list first. +- **MERGE** (key-by-key, local wins): `environment`, `labels`, `annotations`, + `extra_hosts`, `ulimits`, `healthcheck`, and mapping-form `depends_on`. + List-form `environment`/`depends_on` is normalized to a mapping for the merge. +- **OVERRIDE** (local replaces wholesale) — the default, covering scalars + (`image`, `build`, `user`, `working_dir`, `platform`, `pull_policy`, + `hostname`, `container_name`, `networks`), bools (`init`, `read_only`, + `privileged`), and crucially the argv lists `command`/`entrypoint`, which must + not concatenate. +- **Unknown keys default to OVERRIDE** — safe, because `validate()` rejects any + unsupported key afterward regardless of how it merged. + +**The honesty boundary.** Where a faithful merge is not possible — a +MERGE-category key that is neither a mapping nor a normalizable list on one +side, or a CONCAT-category key that is neither a scalar nor a list — resolution +refuses loudly (`cannot merge 'environment' for service 'web': incompatible +forms`) instead of guessing. + +## Non-goals + +- `file:` / cross-file extends — refused; a later follow-up if demand appears. +- Bare-string `extends` — not valid in the current compose spec. +- Spec-exact volume merge-by-target-path — short-form `volumes` concatenate and + podman resolves duplicate mount points; a documented divergence. +- Auto-importing referenced resources (top-level volumes/networks/secrets) — as + in Compose, the extending service must declare what it needs. + +## Testing + +`just test-ci` at 100%: single-level and transitive resolution; each merge +category (concat / merge / override) including `command` override and +`environment` map-and-list normalization; cycle detection; `file:` refusal; +unknown `service:` refusal; incompatible-form refusal; a resolved document then +passing `validate()` and `emit_script()` end to end; and a document with no +`extends` passing through byte-identical. `just lint-ci` clean and +`just check-planning`. + +## Risk + +- **Merge misclassification** (med x med): a key merged with the wrong category + yields wrong output. Mitigated by the explicit category tables, the OVERRIDE + default backstopped by `validate()`, and per-category tests. +- **`C901` on the merge function** (low x low): split by category (a small + helper per CONCAT/MERGE/OVERRIDE) if it crosses the limit, as prior bundles + did. +- **Ambiguous-form input** (low x med): refused loudly rather than guessed, so a + surprising merge never ships silently. diff --git a/tests/test_cli.py b/tests/test_cli.py index 0533408..89ff259 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,7 @@ def test_exports(self) -> None: "EmitOptions", "UnsupportedComposeError", "emit_script", + "resolve_extends", "to_shell", "validate", } @@ -187,6 +188,39 @@ def test_env_file_path_variable_is_live_and_noted( assert "${ENV_DIR-}" in out.out assert "ENV_DIR" in out.err + def test_extends_is_resolved_before_emit( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + doc = { + "services": { + "base": {"image": "app", "environment": {"LOG": "info"}}, + "web": {"extends": {"service": "base"}, "environment": {"PORT": "8080"}}, + } + } + rc = run_main(json.dumps(doc), ["--target", "web", "--image", "i"], monkeypatch) + out = capsys.readouterr().out + assert rc == 0 + # merged web runs the base image with both env vars + assert "app" in out + assert "LOG=info" in out + assert "PORT=8080" in out + + def test_cross_file_extends_is_rejected_by_cli( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + doc = {"services": {"web": {"extends": {"service": "base", "file": "other.yml"}}}} + rc = run_main(json.dumps(doc), ["--target", "web", "--image", "i"], monkeypatch) + assert rc == EXIT_USAGE_ERROR + assert "cross-file" in capsys.readouterr().err + + def test_extends_non_dict_base_is_clean_error( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + doc = {"services": {"base": None, "web": {"extends": {"service": "base"}, "image": "x"}}} + rc = run_main(json.dumps(doc), ["--target", "web", "--image", "i"], monkeypatch) + assert rc == EXIT_USAGE_ERROR + assert "must be a mapping" in capsys.readouterr().err + def test_yaml_anchor_extension_fields_convert( self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_extends.py b/tests/test_extends.py new file mode 100644 index 0000000..461d309 --- /dev/null +++ b/tests/test_extends.py @@ -0,0 +1,188 @@ +from typing import Any + +import pytest + +from compose2pod.exceptions import UnsupportedComposeError +from compose2pod.extends import resolve_extends + + +def _services(doc: dict[str, Any]) -> dict[str, Any]: + return resolve_extends(doc)["services"] + + +class TestResolveExtends: + def test_no_extends_document_is_unchanged(self) -> None: + doc = {"services": {"app": {"image": "x", "environment": {"A": "1"}}}} + assert resolve_extends(doc) == doc + + def test_non_dict_document_passes_through(self) -> None: + assert resolve_extends([1, 2]) == [1, 2] + + def test_non_dict_services_passes_through(self) -> None: + doc = {"services": "nope"} + assert resolve_extends(doc) == doc + + def test_single_level_scalar_override_and_env_merge(self) -> None: + doc = { + "services": { + "base": {"image": "app", "environment": {"LOG": "info", "PORT": "80"}}, + "web": {"extends": {"service": "base"}, "environment": {"PORT": "8080"}}, + } + } + web = _services(doc)["web"] + assert "extends" not in web + assert web["image"] == "app" + assert web["environment"] == {"LOG": "info", "PORT": "8080"} + + def test_transitive_resolution(self) -> None: + doc = { + "services": { + "a": {"image": "base", "environment": {"A": "1"}}, + "b": {"extends": {"service": "a"}, "environment": {"B": "2"}}, + "c": {"extends": {"service": "b"}, "environment": {"C": "3"}}, + } + } + c = _services(doc)["c"] + assert c["image"] == "base" + assert c["environment"] == {"A": "1", "B": "2", "C": "3"} + + def test_command_overrides_not_concatenates(self) -> None: + doc = { + "services": { + "base": {"image": "x", "command": ["a", "b"]}, + "web": {"extends": {"service": "base"}, "command": ["c"]}, + } + } + assert _services(doc)["web"]["command"] == ["c"] + + def test_environment_list_forms_normalized_and_merged(self) -> None: + doc = { + "services": { + "base": {"image": "x", "environment": ["A=1", "SHARED=base", "BARE"]}, + "web": {"extends": {"service": "base"}, "environment": ["SHARED=web"]}, + } + } + assert _services(doc)["web"]["environment"] == {"A": "1", "SHARED": "web", "BARE": None} + + def test_labels_mapping_merge(self) -> None: + doc = { + "services": { + "base": {"image": "x", "labels": {"team": "core", "tier": "base"}}, + "web": {"extends": {"service": "base"}, "labels": {"tier": "web"}}, + } + } + assert _services(doc)["web"]["labels"] == {"team": "core", "tier": "web"} + + def test_depends_on_list_and_map_forms_merge(self) -> None: + doc = { + "services": { + "base": {"image": "x", "depends_on": {"db": {"condition": "service_started"}}}, + "web": {"extends": {"service": "base"}, "depends_on": ["cache"]}, + } + } + merged = _services(doc)["web"]["depends_on"] + assert merged == {"db": {"condition": "service_started"}, "cache": {}} + + def test_cap_add_sequence_concatenates(self) -> None: + doc = { + "services": { + "base": {"image": "x", "cap_add": ["NET_ADMIN"]}, + "web": {"extends": {"service": "base"}, "cap_add": ["SYS_TIME"]}, + } + } + assert _services(doc)["web"]["cap_add"] == ["NET_ADMIN", "SYS_TIME"] + + def test_tmpfs_scalar_normalized_before_concat(self) -> None: + # S108 flags "/tmp" as an insecure hardcoded temp path; this is a + # compose value under test, not a real temp-file usage. + doc = { + "services": { + "base": {"image": "x", "tmpfs": "/run"}, + "web": {"extends": {"service": "base"}, "tmpfs": ["/tmp"]}, # noqa: S108 + } + } + assert _services(doc)["web"]["tmpfs"] == ["/run", "/tmp"] # noqa: S108 + + def test_local_only_key_is_added(self) -> None: + doc = { + "services": { + "base": {"image": "x"}, + "web": {"extends": {"service": "base"}, "user": "1000"}, + } + } + assert _services(doc)["web"] == {"image": "x", "user": "1000"} + + def test_cycle_is_refused(self) -> None: + doc = { + "services": { + "a": {"extends": {"service": "b"}, "image": "x"}, + "b": {"extends": {"service": "a"}, "image": "y"}, + } + } + with pytest.raises(UnsupportedComposeError, match="extends cycle"): + resolve_extends(doc) + + def test_cross_file_extends_is_refused(self) -> None: + doc = {"services": {"web": {"extends": {"service": "base", "file": "other.yml"}}}} + with pytest.raises(UnsupportedComposeError, match=r"'file:' \(cross-file\) is not supported"): + resolve_extends(doc) + + def test_bare_string_extends_is_refused(self) -> None: + doc = {"services": {"web": {"extends": "base", "image": "x"}}} + with pytest.raises(UnsupportedComposeError, match="must be a mapping"): + resolve_extends(doc) + + def test_unknown_extends_key_is_refused(self) -> None: + doc = {"services": {"web": {"extends": {"service": "base", "bogus": 1}}, "base": {"image": "x"}}} + with pytest.raises(UnsupportedComposeError, match="unsupported 'extends' keys"): + resolve_extends(doc) + + def test_non_string_service_is_refused(self) -> None: + doc = {"services": {"web": {"extends": {"service": 5}}}} + with pytest.raises(UnsupportedComposeError, match="extends 'service' must be a string"): + resolve_extends(doc) + + def test_unknown_referenced_service_is_refused(self) -> None: + doc = {"services": {"web": {"extends": {"service": "ghost"}, "image": "x"}}} + with pytest.raises(UnsupportedComposeError, match="extends unknown service 'ghost'"): + resolve_extends(doc) + + def test_incompatible_mapping_form_is_refused(self) -> None: + doc = { + "services": { + "base": {"image": "x", "environment": {"A": "1"}}, + "web": {"extends": {"service": "base"}, "environment": "NOT_A_MAP"}, + } + } + with pytest.raises(UnsupportedComposeError, match="cannot merge 'environment' across incompatible forms"): + resolve_extends(doc) + + def test_diamond_inheritance_resolves_base_once(self) -> None: + doc = { + "services": { + "base": {"image": "app", "environment": {"COMMON": "1"}}, + "web": {"extends": {"service": "base"}, "environment": {"ROLE": "web"}}, + "worker": {"extends": {"service": "base"}, "environment": {"ROLE": "worker"}}, + } + } + services = resolve_extends(doc)["services"] + assert services["web"]["environment"] == {"COMMON": "1", "ROLE": "web"} + assert services["worker"]["environment"] == {"COMMON": "1", "ROLE": "worker"} + + def test_incompatible_sequence_form_is_refused(self) -> None: + doc = { + "services": { + "base": {"image": "x", "cap_add": ["NET_ADMIN"]}, + "web": {"extends": {"service": "base"}, "cap_add": {"bad": "shape"}}, + } + } + with pytest.raises(UnsupportedComposeError, match="cannot merge 'cap_add' across incompatible forms"): + resolve_extends(doc) + + def test_extends_non_dict_base_defers_to_validate(self) -> None: + # A non-dict base cannot be merged; resolve must not crash — it leaves + # the invalid base for validate() to reject. + doc = {"services": {"base": None, "web": {"extends": {"service": "base"}, "image": "x"}}} + result = resolve_extends(doc) + assert "extends" not in result["services"]["web"] + assert result["services"]["base"] is None diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 1e9ed05..9d0871f 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -31,6 +31,10 @@ def test_non_dict_document_raises(self) -> None: with pytest.raises(UnsupportedComposeError, match="must be a mapping"): validate(bad) # ty: ignore[invalid-argument-type] + def test_non_dict_service_value_is_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must be a mapping"): + validate({"services": {"web": None}}) + def test_unsupported_service_key_raises(self) -> None: with pytest.raises(UnsupportedComposeError, match="network_mode"): validate({"services": {"app": {"image": "x", "network_mode": "host"}}})