compose-guard 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ """Compose Guard: lint Docker Compose files for security and reliability problems."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m compose_guard``."""
2
+
3
+ from compose_guard.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
compose_guard/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ """Command-line interface: argument parsing, file discovery and exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import dataclasses
7
+ import os
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import NoReturn, TextIO
12
+
13
+ from compose_guard import __version__
14
+ from compose_guard.config import DEFAULT_CONFIG_NAME, FAIL_ON_CHOICES, Config, load_config
15
+ from compose_guard.engine import lint
16
+ from compose_guard.errors import ComposeGuardError, InputError
17
+ from compose_guard.loader import load_bytes
18
+ from compose_guard.model import ComposeFile
19
+ from compose_guard.report import FORMATS, render
20
+ from compose_guard.rules import RULES, RULES_BY_ID
21
+
22
+ PROG = "compose-guard"
23
+
24
+ EXIT_OK = 0
25
+ EXIT_FINDINGS = 1
26
+ EXIT_USAGE = 2
27
+ EXIT_INTERRUPTED = 130
28
+ EXIT_BROKEN_PIPE = 141
29
+
30
+ # The Compose specification's lookup order for a project's default file.
31
+ DEFAULT_FILES = ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml")
32
+
33
+ STDIN_NAME = "<stdin>"
34
+
35
+
36
+ class _Parser(argparse.ArgumentParser):
37
+ def error(self, message: str) -> NoReturn:
38
+ self.print_usage(sys.stderr)
39
+ self.exit(EXIT_USAGE, f"{self.prog}: error: {message}\n")
40
+
41
+
42
+ DESCRIPTION = "Lint Docker Compose files for security, misconfiguration and reliability problems."
43
+ EPILOG = (
44
+ "exit codes: 0 no unsuppressed finding at or above --fail-on, 1 at least one such finding, "
45
+ "2 usage or input error, 130 interrupted, 141 output pipe closed"
46
+ )
47
+ FILES_HELP = (
48
+ f"Compose files to lint; '-' reads standard input (default: the first of "
49
+ f"{', '.join(DEFAULT_FILES)} in the current directory)"
50
+ )
51
+
52
+
53
+ def build_parser() -> argparse.ArgumentParser:
54
+ parser = _Parser(prog=PROG, description=DESCRIPTION, epilog=EPILOG)
55
+ parser.add_argument("files", nargs="*", metavar="FILE", help=FILES_HELP)
56
+ parser.add_argument("--version", action="version", version=f"{PROG} {__version__}")
57
+ parser.add_argument(
58
+ "--format", choices=FORMATS, default="text", help="output format (default: text)"
59
+ )
60
+ parser.add_argument(
61
+ "--fail-on",
62
+ choices=FAIL_ON_CHOICES,
63
+ default=None,
64
+ help="exit 1 if an unsuppressed finding has this severity or worse (default: error)",
65
+ )
66
+ parser.add_argument(
67
+ "--config",
68
+ metavar="PATH",
69
+ type=Path,
70
+ help=f"configuration file (default: {DEFAULT_CONFIG_NAME} in the current directory)",
71
+ )
72
+ parser.add_argument(
73
+ "--ignore",
74
+ metavar="IDS",
75
+ action="append",
76
+ default=[],
77
+ help="comma-separated rule IDs to ignore, added to the config file's list",
78
+ )
79
+ parser.add_argument(
80
+ "--show-suppressed", action="store_true", help="also list suppressed findings"
81
+ )
82
+ parser.add_argument("--list-rules", action="store_true", help="describe every rule and exit")
83
+ return parser
84
+
85
+
86
+ def _parse_ignore(parser: argparse.ArgumentParser, values: Sequence[str]) -> frozenset[str]:
87
+ ids = {part.strip().upper() for value in values for part in value.split(",") if part.strip()}
88
+ unknown = sorted(ids - set(RULES_BY_ID))
89
+ if unknown:
90
+ parser.error(
91
+ f"unknown rule ID(s) for --ignore: {', '.join(unknown)} "
92
+ f"(valid: {', '.join(RULES_BY_ID)})"
93
+ )
94
+ return frozenset(ids)
95
+
96
+
97
+ def _discover(parser: argparse.ArgumentParser) -> list[str]:
98
+ for name in DEFAULT_FILES:
99
+ if Path(name).is_file():
100
+ return [name]
101
+ parser.error(f"no Compose file given and none of {', '.join(DEFAULT_FILES)} found")
102
+
103
+
104
+ def _read(path: str, stdin: TextIO) -> ComposeFile:
105
+ if path == "-":
106
+ return load_bytes(stdin.buffer.read(), STDIN_NAME)
107
+ target = Path(path)
108
+ # Windows raises PermissionError for directories, not IsADirectoryError.
109
+ if target.is_dir():
110
+ raise InputError("is a directory", file=path)
111
+ try:
112
+ data = target.read_bytes()
113
+ except FileNotFoundError:
114
+ raise InputError("no such file", file=path) from None
115
+ except IsADirectoryError:
116
+ raise InputError("is a directory", file=path) from None
117
+ except OSError as exc:
118
+ raise InputError(f"cannot read file: {exc.strerror}", file=path) from None
119
+ return load_bytes(data, path)
120
+
121
+
122
+ def _config(args: argparse.Namespace) -> Config:
123
+ if args.config is not None:
124
+ return load_config(args.config, RULES_BY_ID)
125
+ default = Path(DEFAULT_CONFIG_NAME)
126
+ if default.is_file():
127
+ return load_config(default, RULES_BY_ID)
128
+ return Config()
129
+
130
+
131
+ def list_rules() -> str:
132
+ blocks = []
133
+ for rule in RULES:
134
+ blocks.append(
135
+ f"{rule.id} {rule.severity.value:<7} {rule.title}\n"
136
+ f" {rule.rationale}\n"
137
+ f" fix: {rule.fix}"
138
+ )
139
+ return "\n\n".join(blocks) + "\n"
140
+
141
+
142
+ def _run(argv: Sequence[str] | None, stdout: TextIO, stdin: TextIO) -> int:
143
+ parser = build_parser()
144
+ args = parser.parse_args(argv)
145
+ if args.list_rules:
146
+ stdout.write(list_rules())
147
+ return EXIT_OK
148
+
149
+ cli_ignores = _parse_ignore(parser, args.ignore)
150
+ paths = args.files or _discover(parser)
151
+ if paths.count("-") > 1:
152
+ parser.error("standard input ('-') can only be given once")
153
+
154
+ file_config = _config(args)
155
+ # Command-line flags add to (--ignore) or override (--fail-on) the config file.
156
+ config = dataclasses.replace(
157
+ file_config,
158
+ ignore=file_config.ignore | cli_ignores,
159
+ fail_on=args.fail_on or file_config.fail_on,
160
+ )
161
+ files = [_read(path, stdin) for path in paths]
162
+ report = lint(files, config)
163
+ stdout.write(render(report, args.format, show_suppressed=args.show_suppressed))
164
+ stdout.flush()
165
+ return EXIT_FINDINGS if report.fails(config.fail_on or "error") else EXIT_OK
166
+
167
+
168
+ def main(argv: Sequence[str] | None = None) -> int:
169
+ try:
170
+ return _run(argv, sys.stdout, sys.stdin)
171
+ except ComposeGuardError as exc:
172
+ sys.stderr.write(f"{PROG}: error: {exc}\n")
173
+ return EXIT_USAGE
174
+ except KeyboardInterrupt:
175
+ sys.stderr.write(f"{PROG}: interrupted\n")
176
+ return EXIT_INTERRUPTED
177
+ except BrokenPipeError:
178
+ # The reader went away (e.g. `| head`). Point stdout at devnull so the interpreter's
179
+ # final flush does not raise again, and exit quietly.
180
+ devnull = os.open(os.devnull, os.O_WRONLY)
181
+ os.dup2(devnull, sys.stdout.fileno())
182
+ return EXIT_BROKEN_PIPE
@@ -0,0 +1,150 @@
1
+ """The ``.compose-guard.yml`` configuration file.
2
+
3
+ ```yaml
4
+ ignore: [CD015]
5
+ services:
6
+ worker:
7
+ ignore: [CD007]
8
+ severity:
9
+ CD010: warning
10
+ fail_on: warning
11
+ ```
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Collection, Mapping
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from compose_guard.errors import InputError
21
+ from compose_guard.loader import parse_mapping
22
+ from compose_guard.model import Located, LocatedMap, Severity, Value
23
+ from compose_guard.rules.base import mapping, sequence, text
24
+
25
+ DEFAULT_CONFIG_NAME = ".compose-guard.yml"
26
+ FAIL_ON_CHOICES = ("error", "warning", "note", "never")
27
+
28
+ _TOP_LEVEL_KEYS = ("ignore", "services", "severity", "fail_on")
29
+ _SERVICE_KEYS = ("ignore",)
30
+ _SEVERITIES = tuple(severity.value for severity in Severity)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Config:
35
+ ignore: frozenset[str] = frozenset()
36
+ service_ignores: Mapping[str, frozenset[str]] = field(default_factory=dict)
37
+ severity: Mapping[str, Severity] = field(default_factory=dict)
38
+ fail_on: str | None = None
39
+
40
+ def ignores(self, rule_id: str, service: str | None) -> bool:
41
+ """Whether ``rule_id`` is ignored globally or for ``service``."""
42
+ if rule_id in self.ignore:
43
+ return True
44
+ return service is not None and rule_id in self.service_ignores.get(service, frozenset())
45
+
46
+
47
+ def load_config(path: Path, known_rules: Collection[str]) -> Config:
48
+ """Read and validate a config file. Raises :class:`InputError` naming the problem."""
49
+ display = str(path)
50
+ try:
51
+ data = path.read_bytes()
52
+ except FileNotFoundError:
53
+ raise InputError("config file not found", file=display) from None
54
+ except OSError as exc:
55
+ raise InputError(f"cannot read config file: {exc.strerror}", file=display) from None
56
+ try:
57
+ source = data.decode("utf-8-sig")
58
+ except UnicodeDecodeError:
59
+ raise InputError("config file is not valid UTF-8", file=display) from None
60
+ root = parse_mapping(source, display)
61
+ if root is None:
62
+ return Config()
63
+ return _Parser(display, known_rules).parse(root)
64
+
65
+
66
+ class _Parser:
67
+ def __init__(self, path: str, known_rules: Collection[str]) -> None:
68
+ self._path = path
69
+ self._known = known_rules
70
+
71
+ def _error(self, message: str, at: Located[object]) -> InputError:
72
+ return InputError(message, file=self._path, line=at.line)
73
+
74
+ def _check_keys(self, raw: LocatedMap, allowed: tuple[str, ...], where: str) -> None:
75
+ for name in raw:
76
+ if name not in allowed:
77
+ raise self._error(
78
+ f"unknown key '{name}' in {where}; valid keys are: {', '.join(allowed)}",
79
+ raw.key(name),
80
+ )
81
+
82
+ def _rule_id(self, value: Located[Value]) -> str:
83
+ rule_id = text(value)
84
+ if rule_id is None or rule_id not in self._known:
85
+ shown = rule_id if rule_id is not None else "(not a string)"
86
+ raise self._error(
87
+ f"unknown rule ID '{shown}'; valid IDs are: {', '.join(sorted(self._known))}",
88
+ value,
89
+ )
90
+ return rule_id
91
+
92
+ def _rule_list(self, value: Located[Value], where: str) -> frozenset[str]:
93
+ items = sequence(value)
94
+ if items is None:
95
+ raise self._error(f"'{where}' must be a list of rule IDs", value)
96
+ return frozenset(self._rule_id(item) for item in items)
97
+
98
+ def parse(self, root: LocatedMap) -> Config:
99
+ self._check_keys(root, _TOP_LEVEL_KEYS, "config file")
100
+ ignore = self._rule_list(root["ignore"], "ignore") if "ignore" in root else frozenset()
101
+ return Config(
102
+ ignore=ignore,
103
+ service_ignores=self._services(root.get("services")),
104
+ severity=self._severity(root.get("severity")),
105
+ fail_on=self._fail_on(root.get("fail_on")),
106
+ )
107
+
108
+ def _services(self, value: Located[Value] | None) -> dict[str, frozenset[str]]:
109
+ if value is None:
110
+ return {}
111
+ services = mapping(value)
112
+ if services is None:
113
+ raise self._error("'services' must be a mapping of service names", value)
114
+ result = {}
115
+ for name in services:
116
+ settings = mapping(services[name])
117
+ if settings is None:
118
+ raise self._error(f"'services.{name}' must be a mapping", services[name])
119
+ self._check_keys(settings, _SERVICE_KEYS, f"'services.{name}'")
120
+ if "ignore" in settings:
121
+ result[name] = self._rule_list(settings["ignore"], f"services.{name}.ignore")
122
+ return result
123
+
124
+ def _severity(self, value: Located[Value] | None) -> dict[str, Severity]:
125
+ if value is None:
126
+ return {}
127
+ overrides = mapping(value)
128
+ if overrides is None:
129
+ raise self._error("'severity' must be a mapping of rule IDs to severities", value)
130
+ result = {}
131
+ for name in overrides:
132
+ rule_id = self._rule_id(overrides.key(name))
133
+ level = text(overrides[name])
134
+ if level not in _SEVERITIES:
135
+ raise self._error(
136
+ f"invalid severity for {rule_id}; valid values are: {', '.join(_SEVERITIES)}",
137
+ overrides[name],
138
+ )
139
+ result[rule_id] = Severity(level)
140
+ return result
141
+
142
+ def _fail_on(self, value: Located[Value] | None) -> str | None:
143
+ if value is None:
144
+ return None
145
+ level = text(value)
146
+ if level not in FAIL_ON_CHOICES:
147
+ raise self._error(
148
+ f"invalid 'fail_on'; valid values are: {', '.join(FAIL_ON_CHOICES)}", value
149
+ )
150
+ return level
@@ -0,0 +1,73 @@
1
+ """Lint loaded files: run rules, apply severity overrides and suppressions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+
10
+ from compose_guard.config import Config
11
+ from compose_guard.model import ComposeFile, Finding, Severity
12
+ from compose_guard.rules import RULES_BY_ID, run_rules, sort_key
13
+ from compose_guard.suppress import parse_suppressions
14
+
15
+
16
+ class Suppression(Enum):
17
+ """Why a finding was suppressed. Values are SARIF ``suppression.kind`` values."""
18
+
19
+ INLINE = "inSource"
20
+ CONFIG = "external"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Result:
25
+ finding: Finding
26
+ suppression: Suppression | None = None
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Report:
31
+ files: tuple[str, ...]
32
+ results: tuple[Result, ...]
33
+
34
+ @property
35
+ def findings(self) -> list[Finding]:
36
+ """Unsuppressed findings."""
37
+ return [r.finding for r in self.results if r.suppression is None]
38
+
39
+ @property
40
+ def suppressed(self) -> list[Result]:
41
+ return [r for r in self.results if r.suppression is not None]
42
+
43
+ def count(self, severity: Severity) -> int:
44
+ return sum(1 for finding in self.findings if finding.severity is severity)
45
+
46
+ def fails(self, fail_on: str) -> bool:
47
+ """Whether any unsuppressed finding is at or above ``fail_on`` (or never, for "never")."""
48
+ if fail_on == "never":
49
+ return False
50
+ threshold = Severity(fail_on).rank
51
+ return any(finding.severity.rank >= threshold for finding in self.findings)
52
+
53
+
54
+ def lint_file(compose: ComposeFile, config: Config) -> list[Result]:
55
+ inline = parse_suppressions(compose.source, compose.path, RULES_BY_ID)
56
+ results = []
57
+ for finding in run_rules(compose):
58
+ override = config.severity.get(finding.rule_id)
59
+ if override is not None:
60
+ finding = dataclasses.replace(finding, severity=override)
61
+ if inline.covers(finding.rule_id, finding.line):
62
+ results.append(Result(finding, Suppression.INLINE))
63
+ elif config.ignores(finding.rule_id, finding.service):
64
+ results.append(Result(finding, Suppression.CONFIG))
65
+ else:
66
+ results.append(Result(finding))
67
+ return results
68
+
69
+
70
+ def lint(files: Sequence[ComposeFile], config: Config) -> Report:
71
+ results = [result for compose in files for result in lint_file(compose, config)]
72
+ results.sort(key=lambda result: sort_key(result.finding))
73
+ return Report(files=tuple(compose.path for compose in files), results=tuple(results))
@@ -0,0 +1,28 @@
1
+ """Exception types raised by Compose Guard."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ComposeGuardError(Exception):
7
+ """Base class for all errors Compose Guard reports to the user."""
8
+
9
+
10
+ class InputError(ComposeGuardError):
11
+ """A problem with an input file or configuration.
12
+
13
+ ``file`` and ``line`` locate the problem when known. ``str()`` renders the
14
+ ``<file>:<line>: <message>`` form used on stderr.
15
+ """
16
+
17
+ def __init__(self, message: str, *, file: str | None = None, line: int | None = None) -> None:
18
+ super().__init__(message)
19
+ self.message = message
20
+ self.file = file
21
+ self.line = line
22
+
23
+ def __str__(self) -> str:
24
+ if self.file is None:
25
+ return self.message
26
+ if self.line is None:
27
+ return f"{self.file}: {self.message}"
28
+ return f"{self.file}:{self.line}: {self.message}"
@@ -0,0 +1,20 @@
1
+ """Detect Compose variable interpolation (detection only, values are never resolved).
2
+
3
+ Compose substitutes ``${VAR}``, ``${VAR:-default}``, ``${VAR-default}``, ``${VAR:?err}``,
4
+ ``${VAR?err}``, ``${VAR:+alt}``, ``${VAR+alt}`` and ``$VAR``. ``$$`` is an escaped literal
5
+ ``$``. Rules treat an interpolated value as unknown.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _NAME = r"[A-Za-z_][A-Za-z0-9_]*"
13
+
14
+ # "$$" is matched first so an escaped dollar is consumed before it can start a reference.
15
+ _TOKEN = re.compile(rf"\$\$|\$\{{{_NAME}(?:(?::?[-?+])[^}}]*)?\}}|\${_NAME}")
16
+
17
+
18
+ def contains_interpolation(text: str) -> bool:
19
+ """Return True if ``text`` contains at least one variable reference."""
20
+ return any(match.group() != "$$" for match in _TOKEN.finditer(text))
@@ -0,0 +1,196 @@
1
+ """Load Compose files into located values.
2
+
3
+ PyYAML's ``load`` functions throw away source positions, so this module calls ``yaml.compose``
4
+ with ``SafeLoader`` to get a node tree with marks and converts the nodes itself. Aliases are
5
+ already shared nodes in that tree; merge keys (``<<``) are applied here with YAML 1.1 semantics.
6
+ No Python objects other than plain scalars, ``LocatedMap`` and tuples are ever constructed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Iterable
12
+
13
+ import yaml
14
+ from yaml.constructor import SafeConstructor
15
+ from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
16
+
17
+ from compose_guard.errors import InputError
18
+ from compose_guard.model import ComposeFile, Located, LocatedMap, Scalar, Service, Value
19
+
20
+ MAX_NODES = 100_000
21
+ """Upper bound on values produced while converting one document (alias-bomb protection)."""
22
+
23
+ MAX_DEPTH = 100
24
+ """Upper bound on nesting depth, far beyond anything a real Compose file needs."""
25
+
26
+ _TAG = "tag:yaml.org,2002:"
27
+ _MERGE_TAG = _TAG + "merge"
28
+
29
+ _constructor = SafeConstructor()
30
+
31
+ # Only these scalar constructors are used. Any other tag (timestamps, binary, Compose's own
32
+ # ``!reset`` / ``!override``) keeps its source text as a string.
33
+ _SCALAR_CONSTRUCTORS: dict[str, Callable[[ScalarNode], Scalar]] = {
34
+ _TAG + "null": lambda node: None,
35
+ _TAG + "bool": _constructor.construct_yaml_bool,
36
+ _TAG + "int": _constructor.construct_yaml_int,
37
+ _TAG + "float": _constructor.construct_yaml_float,
38
+ }
39
+
40
+
41
+ def load_bytes(data: bytes, path: str) -> ComposeFile:
42
+ """Decode ``data`` as UTF-8 (a leading BOM is allowed) and load it."""
43
+ try:
44
+ text = data.decode("utf-8-sig")
45
+ except UnicodeDecodeError as exc:
46
+ line = data[: exc.start].count(b"\n") + 1
47
+ raise InputError("file is not valid UTF-8", file=path, line=line) from None
48
+ return load_text(text, path)
49
+
50
+
51
+ def load_text(text: str, path: str) -> ComposeFile:
52
+ """Parse ``text`` and build a :class:`ComposeFile`. Raises :class:`InputError`."""
53
+ root = parse_mapping(text, path)
54
+ if root is None:
55
+ raise InputError("file is empty", file=path, line=1)
56
+ return ComposeFile(path=path, source=text, root=root, services=_services(root, path))
57
+
58
+
59
+ def parse_mapping(text: str, path: str) -> LocatedMap | None:
60
+ """Parse a single YAML document whose top level must be a mapping.
61
+
62
+ Returns ``None`` for an empty document. Used for Compose files and the config file alike.
63
+ """
64
+ root_node = _compose(text, path)
65
+ if root_node is None:
66
+ return None
67
+ if not isinstance(root_node, MappingNode):
68
+ raise InputError("top-level value must be a mapping", file=path, line=_line(root_node))
69
+ root = _Converter(path).convert(root_node)
70
+ assert isinstance(root.value, LocatedMap) # a MappingNode always converts to a LocatedMap
71
+ return root.value
72
+
73
+
74
+ def _compose(text: str, path: str) -> Node | None:
75
+ try:
76
+ node: Node | None = yaml.compose(text, Loader=yaml.SafeLoader)
77
+ except yaml.MarkedYAMLError as exc:
78
+ mark = exc.problem_mark or exc.context_mark
79
+ line = mark.line + 1 if mark is not None else None
80
+ if exc.context == "expected a single document in the stream":
81
+ raise InputError(
82
+ "multiple YAML documents in one file are not supported", file=path, line=line
83
+ ) from None
84
+ raise InputError(f"invalid YAML: {exc.problem}", file=path, line=line) from None
85
+ except yaml.YAMLError as exc: # a reader error without a mark
86
+ raise InputError(f"invalid YAML: {exc}", file=path) from None
87
+ except RecursionError:
88
+ raise InputError("document is nested too deeply", file=path) from None
89
+ return node
90
+
91
+
92
+ def _services(root: LocatedMap, path: str) -> tuple[Service, ...]:
93
+ if "services" not in root:
94
+ raise InputError("missing top-level 'services' key", file=path, line=1)
95
+ services_key, services_value = root.entry("services")
96
+ if not isinstance(services_value.value, LocatedMap):
97
+ raise InputError("'services' must be a mapping", file=path, line=services_key.line)
98
+
99
+ services = []
100
+ for name in services_value.value:
101
+ if name.startswith("x-"):
102
+ continue
103
+ key, value = services_value.value.entry(name)
104
+ if not isinstance(value.value, LocatedMap):
105
+ raise InputError(f"service '{name}' must be a mapping", file=path, line=key.line)
106
+ services.append(Service(name=name, line=key.line, column=key.column, raw=value.value))
107
+ return tuple(services)
108
+
109
+
110
+ def _line(node: Node) -> int:
111
+ return node.start_mark.line + 1
112
+
113
+
114
+ def _column(node: Node) -> int:
115
+ return node.start_mark.column + 1
116
+
117
+
118
+ class _Converter:
119
+ """Convert a composed node tree to located values, counting what it produces."""
120
+
121
+ def __init__(self, path: str) -> None:
122
+ self._path = path
123
+ self._count = 0
124
+ self._active: set[int] = set()
125
+
126
+ def convert(self, node: Node, depth: int = 0) -> Located[Value]:
127
+ self._count += 1
128
+ if self._count > MAX_NODES:
129
+ raise InputError("document expands to too many nodes", file=self._path)
130
+ if depth > MAX_DEPTH:
131
+ raise InputError("document is nested too deeply", file=self._path, line=_line(node))
132
+
133
+ if isinstance(node, ScalarNode):
134
+ return Located(_scalar(node), _line(node), _column(node), raw=node.value)
135
+
136
+ if id(node) in self._active:
137
+ raise InputError("recursive alias", file=self._path, line=_line(node))
138
+ self._active.add(id(node))
139
+ try:
140
+ value: Value
141
+ if isinstance(node, MappingNode):
142
+ value = self._mapping(node, depth)
143
+ else:
144
+ assert isinstance(node, SequenceNode) # the only remaining node kind
145
+ value = tuple(self.convert(item, depth + 1) for item in node.value)
146
+ finally:
147
+ self._active.discard(id(node))
148
+ return Located(value, _line(node), _column(node))
149
+
150
+ def _mapping(self, node: MappingNode, depth: int) -> LocatedMap:
151
+ merged: dict[str, tuple[Located[str], Located[Value]]] = {}
152
+ explicit: dict[str, tuple[Located[str], Located[Value]]] = {}
153
+
154
+ for key_node, value_node in node.value:
155
+ if key_node.tag == _MERGE_TAG:
156
+ # Earlier merge sources win over later ones, so only fill keys not yet seen.
157
+ for source in self._merge_sources(value_node, depth):
158
+ for name in source:
159
+ merged.setdefault(name, source.entry(name))
160
+ continue
161
+
162
+ key = self._key(key_node)
163
+ if key.value in explicit:
164
+ raise InputError(f"duplicate key '{key.value}'", file=self._path, line=key.line)
165
+ explicit[key.value] = (key, self.convert(value_node, depth + 1))
166
+
167
+ # Keys written in the mapping itself win over merged keys.
168
+ return LocatedMap({**merged, **explicit})
169
+
170
+ def _merge_sources(self, node: Node, depth: int) -> Iterable[LocatedMap]:
171
+ items = node.value if isinstance(node, SequenceNode) else [node]
172
+ sources = []
173
+ for item in items:
174
+ if not isinstance(item, MappingNode):
175
+ raise InputError(
176
+ "merge key '<<' must refer to a mapping or a list of mappings",
177
+ file=self._path,
178
+ line=_line(item),
179
+ )
180
+ converted = self.convert(item, depth + 1).value
181
+ assert isinstance(converted, LocatedMap) # a MappingNode always converts to one
182
+ sources.append(converted)
183
+ return sources
184
+
185
+ def _key(self, node: Node) -> Located[str]:
186
+ if not isinstance(node, ScalarNode):
187
+ raise InputError("mapping keys must be scalars", file=self._path, line=_line(node))
188
+ # Keys keep their source text: ``on:`` stays "on" instead of becoming True.
189
+ return Located(node.value, _line(node), _column(node), raw=node.value)
190
+
191
+
192
+ def _scalar(node: ScalarNode) -> Scalar:
193
+ construct = _SCALAR_CONSTRUCTORS.get(node.tag)
194
+ if construct is None:
195
+ return str(node.value)
196
+ return construct(node)