eggpipe 0.3.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.
File without changes
@@ -0,0 +1,35 @@
1
+ """Application technical configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class AppConfig(BaseSettings):
11
+ model_config = SettingsConfigDict(
12
+ env_prefix="EGGPIPE_",
13
+ env_file=".env",
14
+ env_file_encoding="utf-8",
15
+ case_sensitive=False,
16
+ extra="ignore",
17
+ )
18
+
19
+ log_level: str = "INFO"
20
+ log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
21
+ log_destination: str = "stderr"
22
+
23
+ rules_file: Path | None = None
24
+ output_root: Path | None = None
25
+ force: bool = False
26
+ include_disabled: bool = False
27
+ allow_outside_imports: bool = False
28
+ clean: bool = False
29
+ debug: bool = False
30
+ verbose: bool = False
31
+ quiet: bool = False
32
+ watch_debounce_ms: int = 300
33
+
34
+
35
+ __all__ = ["AppConfig"]
@@ -0,0 +1,40 @@
1
+ """Single setup point for CLI logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from eggpipe.config.app_config import AppConfig
10
+
11
+ LOGGER_NAME = "eggpipe"
12
+
13
+
14
+ def get_logger(name: str = LOGGER_NAME) -> logging.Logger:
15
+ logger = logging.getLogger(name)
16
+ if not logger.handlers:
17
+ logger.addHandler(logging.NullHandler())
18
+ return logger
19
+
20
+
21
+ def setup_logging(config: AppConfig) -> logging.Logger:
22
+ logger = logging.getLogger(LOGGER_NAME)
23
+ logger.setLevel(getattr(logging, config.log_level.upper(), logging.INFO))
24
+ logger.handlers.clear()
25
+ logger.propagate = False
26
+
27
+ if config.log_destination.startswith("file:"):
28
+ path = Path(config.log_destination.removeprefix("file:"))
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ handler: logging.Handler = logging.FileHandler(path, encoding="utf-8")
31
+ elif config.log_destination == "stdout":
32
+ handler = logging.StreamHandler(sys.stdout)
33
+ else:
34
+ handler = logging.StreamHandler(sys.stderr)
35
+ handler.setFormatter(logging.Formatter(config.log_format))
36
+ logger.addHandler(handler)
37
+ return logger
38
+
39
+
40
+ __all__ = ["LOGGER_NAME", "get_logger", "setup_logging"]
@@ -0,0 +1,162 @@
1
+ """Business configuration in .eggpipe.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import (
9
+ BaseModel,
10
+ BeforeValidator,
11
+ ConfigDict,
12
+ Field,
13
+ ValidationError,
14
+ field_validator,
15
+ model_validator,
16
+ )
17
+
18
+ from eggpipe.core.model import InputSelection
19
+ from eggpipe.core.naming import NamingStyle
20
+ from eggpipe.utils.exceptions import ConfigValidationError
21
+ from eggpipe.utils.yaml import YamlLoadError, load_yaml
22
+
23
+ Layout = Literal["monolith", "file-per-model", "directory-per-model"]
24
+
25
+
26
+ class ResourceConfig(BaseModel):
27
+ path: Path
28
+ as_: str | None = Field(default=None, alias="as")
29
+
30
+ model_config = ConfigDict(
31
+ extra="forbid",
32
+ frozen=True,
33
+ populate_by_name=True,
34
+ )
35
+
36
+ @property
37
+ def alias(self) -> str | None:
38
+ return self.as_
39
+
40
+
41
+ def _coerce_resource(value: object) -> object:
42
+ if isinstance(value, str):
43
+ return {"path": value}
44
+ return value
45
+
46
+
47
+ ResourceInput = Annotated[ResourceConfig, BeforeValidator(_coerce_resource)]
48
+
49
+
50
+ class ModelOverride(BaseModel):
51
+ files: tuple[str, ...] | None = None
52
+ resources: tuple[ResourceInput, ...] | None = None
53
+
54
+ model_config = ConfigDict(extra="forbid", frozen=True)
55
+
56
+
57
+ class GeneratorConfig(BaseModel):
58
+ id: str = Field(..., pattern=r"^[a-z][a-z0-9-]*$")
59
+ enabled: bool = True
60
+ input: InputSelection = Field(default_factory=InputSelection)
61
+ output_dir: Path
62
+ on_conflict: Literal["overwrite", "skip", "error"] = "overwrite"
63
+ cleanup: Literal["off", "prompt", "enabled"] = "off"
64
+ options: dict[str, object] = Field(default_factory=dict)
65
+
66
+ model_config = ConfigDict(extra="forbid", frozen=True)
67
+
68
+ @field_validator("output_dir")
69
+ @classmethod
70
+ def _output_dir_must_be_relative(cls, value: Path) -> Path:
71
+ if value.is_absolute():
72
+ raise ValueError("output_dir must be relative")
73
+ return value
74
+
75
+
76
+ class DefaultsConfig(BaseModel):
77
+ naming_convention: NamingStyle | None = None
78
+
79
+ model_config = ConfigDict(extra="forbid", frozen=True)
80
+
81
+
82
+ class SourceConfig(BaseModel):
83
+ path: Path
84
+ layout: Layout
85
+ files: tuple[str, ...] | None = None
86
+ model_dirs: tuple[str, ...] | None = None
87
+ models: dict[str, ModelOverride] = Field(default_factory=dict)
88
+ input: InputSelection = Field(default_factory=InputSelection)
89
+
90
+ model_config = ConfigDict(extra="forbid", frozen=True)
91
+
92
+ @model_validator(mode="after")
93
+ def _validate_layout(self) -> SourceConfig:
94
+ if self.layout == "monolith":
95
+ if self.files is not None or self.model_dirs is not None or self.models:
96
+ raise ValueError("monolith does not support files/model_dirs/models")
97
+ return self
98
+ if not self.files:
99
+ raise ValueError(f"{self.layout} requires non-empty files")
100
+ if self.layout == "file-per-model":
101
+ if self.model_dirs is not None or self.models:
102
+ raise ValueError("file-per-model does not support model_dirs/models")
103
+ return self
104
+ if not self.model_dirs:
105
+ raise ValueError("directory-per-model requires non-empty model_dirs")
106
+ return self
107
+
108
+
109
+ class DiagnosticsConfig(BaseModel):
110
+ stale_dates: bool = False
111
+ ignore: frozenset[str] = frozenset()
112
+
113
+ model_config = ConfigDict(extra="forbid", frozen=True)
114
+
115
+
116
+ class ProjectConfig(BaseModel):
117
+ version: Literal["1.0"]
118
+ defaults: DefaultsConfig = Field(default_factory=DefaultsConfig)
119
+ diagnostics: DiagnosticsConfig = Field(default_factory=DiagnosticsConfig)
120
+ variables: dict[str, str] = Field(default_factory=dict)
121
+ resources: tuple[ResourceInput, ...] = ()
122
+ sources: tuple[SourceConfig, ...] = ()
123
+ generators: tuple[GeneratorConfig, ...] = ()
124
+
125
+ model_config = ConfigDict(extra="forbid", frozen=True)
126
+
127
+
128
+ class ProjectConfigLoader:
129
+ """Loading and discovery of .eggpipe.yaml."""
130
+
131
+ def load(self, path: Path) -> ProjectConfig:
132
+ try:
133
+ data = load_yaml(path)
134
+ except YamlLoadError as exc:
135
+ raise ConfigValidationError(f"{path}: {exc.code}: {exc.message}") from exc
136
+ try:
137
+ return ProjectConfig.model_validate(data)
138
+ except ValidationError as exc:
139
+ raise ConfigValidationError(f"{path}: {exc}") from exc
140
+
141
+ @classmethod
142
+ def discover(cls, start: Path | None = None) -> Path | None:
143
+ current = (start or Path.cwd()).resolve()
144
+ for directory in (current, *current.parents):
145
+ for name in (".eggpipe.yaml",):
146
+ candidate = directory / name
147
+ if candidate.is_file():
148
+ return candidate
149
+ return None
150
+
151
+
152
+ __all__ = [
153
+ "DefaultsConfig",
154
+ "DiagnosticsConfig",
155
+ "GeneratorConfig",
156
+ "Layout",
157
+ "ModelOverride",
158
+ "ProjectConfig",
159
+ "ProjectConfigLoader",
160
+ "ResourceConfig",
161
+ "SourceConfig",
162
+ ]
File without changes
@@ -0,0 +1,431 @@
1
+ """Document composition per ADR-0001."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from eggpipe.core.discovery import ModelUnit, SourceGroup
9
+ from eggpipe.core.document import ImportSpec, ModelDocument
10
+ from eggpipe.core.layers import merge_model_layers
11
+ from eggpipe.core.model import (
12
+ CustomType,
13
+ Entity,
14
+ EnumDef,
15
+ ExtendsRef,
16
+ SourceModel,
17
+ )
18
+ from eggpipe.core.parser import Parser
19
+ from eggpipe.interfaces.diagnostics import Diagnostic, error
20
+ from eggpipe.interfaces.generator import ImportedFile
21
+ from eggpipe.utils.exceptions import DocumentError
22
+
23
+ MAX_IMPORT_DEPTH = 10
24
+ MAX_EXTENDS_DEPTH = 10
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CompositionResult:
29
+ model: SourceModel
30
+ imports: dict[str, ImportedFile]
31
+ documents: tuple[ModelDocument, ...]
32
+ diagnostics: list[Diagnostic] = field(default_factory=list)
33
+
34
+
35
+ @dataclass
36
+ class ResolveState:
37
+ project_root: Path
38
+ allow_outside: bool
39
+ cache: dict[Path, ModelDocument] = field(default_factory=dict)
40
+ order: list[Path] = field(default_factory=list)
41
+ closures: dict[Path, set[Path]] = field(default_factory=dict)
42
+ aliases: dict[Path, dict[str, Path]] = field(default_factory=dict)
43
+ resources: dict[str, ImportedFile] = field(default_factory=dict)
44
+ resource_aliases: set[str] = field(default_factory=set)
45
+ alias_names: dict[Path, set[str]] = field(default_factory=dict)
46
+
47
+
48
+ class Composition:
49
+ def __init__(
50
+ self,
51
+ parser: Parser,
52
+ *,
53
+ project_root: Path,
54
+ allow_outside: bool = False,
55
+ ) -> None:
56
+ self.parser = parser
57
+ self.project_root = project_root.resolve()
58
+ self.allow_outside = allow_outside
59
+
60
+ def resolve(
61
+ self,
62
+ sources: list[Path],
63
+ groups: list[SourceGroup] | None = None,
64
+ *,
65
+ resources: dict[str, ImportedFile] | None = None,
66
+ ) -> CompositionResult:
67
+ unit_refs = _unit_refs(sources, groups)
68
+ if not unit_refs:
69
+ raise DocumentError("EG000", None, "no model sources")
70
+ state = ResolveState(
71
+ project_root=self.project_root,
72
+ allow_outside=self.allow_outside,
73
+ resources=dict(resources or {}),
74
+ resource_aliases=set(resources or {}),
75
+ )
76
+ diagnostics: list[Diagnostic] = []
77
+ visited_keys: list[Path] = []
78
+ grouped: dict[int, tuple[SourceGroup, list[Path]]] = {}
79
+ for group, unit in unit_refs:
80
+ keys = self._visit_unit(unit, state, None, None, diagnostics)
81
+ if not keys:
82
+ continue
83
+ visited_keys.extend(keys)
84
+ if group is not None:
85
+ _, group_keys = grouped.setdefault(id(group), (group, []))
86
+ group_keys.extend(keys)
87
+ if not visited_keys:
88
+ raise DocumentError("EG000", None, "no model sources")
89
+
90
+ resolved = self._resolve_extends(state)
91
+ selections = self._selections(state, list(grouped.values()), diagnostics)
92
+ entities: list[Entity] = []
93
+ enums: list[EnumDef] = []
94
+ types: list[CustomType] = []
95
+ seen_entities: set[str] = set()
96
+ seen_enums: set[str] = set()
97
+ seen_types: set[str] = set()
98
+ for path in state.order:
99
+ document = state.cache[path]
100
+ entity_names, enum_names, type_names = selections.get(path, (None, None, None))
101
+ for entity in document.model.entities:
102
+ if entity_names is not None and entity.name not in entity_names:
103
+ continue
104
+ if entity.name in seen_entities:
105
+ raise DocumentError(
106
+ "EG002",
107
+ path,
108
+ f"duplicate entity name after composition: {entity.name}",
109
+ )
110
+ seen_entities.add(entity.name)
111
+ entities.append(resolved[path][entity.name])
112
+ for enum in document.model.enums:
113
+ if enum_names is not None and enum.name not in enum_names:
114
+ continue
115
+ if enum.name in seen_enums:
116
+ raise DocumentError(
117
+ "EG002",
118
+ path,
119
+ f"duplicate enum name after composition: {enum.name}",
120
+ )
121
+ seen_enums.add(enum.name)
122
+ enums.append(enum)
123
+ for custom_type in document.model.types:
124
+ if type_names is not None and custom_type.name not in type_names:
125
+ continue
126
+ if custom_type.name in seen_types:
127
+ raise DocumentError(
128
+ "EG002",
129
+ path,
130
+ f"duplicate type name after composition: {custom_type.name}",
131
+ )
132
+ seen_types.add(custom_type.name)
133
+ types.append(custom_type)
134
+
135
+ first = state.cache[visited_keys[0]]
136
+ model = SourceModel(
137
+ name=first.model.name,
138
+ description=first.model.description,
139
+ entities=tuple(entities),
140
+ enums=tuple(enums),
141
+ types=tuple(types),
142
+ )
143
+ return CompositionResult(
144
+ model=model,
145
+ imports=dict(state.resources),
146
+ documents=tuple(state.cache[path] for path in state.order),
147
+ diagnostics=diagnostics,
148
+ )
149
+
150
+ def _selections(
151
+ self,
152
+ state: ResolveState,
153
+ grouped: list[tuple[SourceGroup, list[Path]]],
154
+ diagnostics: list[Diagnostic],
155
+ ) -> dict[Path, tuple[set[str] | None, set[str] | None, set[str] | None]]:
156
+ result: dict[Path, tuple[set[str] | None, set[str] | None, set[str] | None]] = {}
157
+ for group, keys in grouped:
158
+ documents = [state.cache[key] for key in keys if key in state.cache]
159
+ if not documents:
160
+ continue
161
+ entities = [item.name for document in documents for item in document.model.entities]
162
+ enums = [item.name for document in documents for item in document.model.enums]
163
+ types = [item.name for document in documents for item in document.model.types]
164
+ location = group.paths[0].resolve() if group.paths else None
165
+ selection = (
166
+ _selected(group.input.entities, entities, "entities", location, diagnostics),
167
+ _selected(group.input.enums, enums, "enums", location, diagnostics),
168
+ _selected(group.input.types, types, "types", location, diagnostics),
169
+ )
170
+ for key in keys:
171
+ result.setdefault(key, selection)
172
+ return result
173
+
174
+ def _visit_unit(
175
+ self,
176
+ unit: ModelUnit,
177
+ state: ResolveState,
178
+ root_format: str | None,
179
+ root_version: str | None,
180
+ diagnostics: list[Diagnostic],
181
+ ) -> tuple[Path, ...]:
182
+ if not unit.layered:
183
+ keys: list[Path] = []
184
+ for path in unit.paths:
185
+ canonical = path.resolve()
186
+ document = self._visit(canonical, state, (), root_format, root_version)
187
+ if unit.single_entity and len(document.model.entities) != 1:
188
+ raise DocumentError(
189
+ "EG031",
190
+ canonical,
191
+ "a file-per-model file must contain exactly one entity",
192
+ )
193
+ if root_format is None:
194
+ root_format = document.format
195
+ root_version = document.version
196
+ keys.append(canonical)
197
+ return tuple(keys)
198
+
199
+ documents = [self.parser.parse_document(path.resolve()) for path in unit.paths]
200
+ merged = merge_model_layers(documents, self.parser.registry)
201
+ diagnostics.extend(merged.diagnostics)
202
+ document = merged.document
203
+ self._ensure_compatible(document, root_format, root_version, document.path)
204
+ key = (unit.model_dir or unit.paths[0].parent).resolve()
205
+ state.cache[key] = document
206
+ state.order.append(key)
207
+ state.closures[key] = {key}
208
+ state.aliases[key] = {}
209
+ state.alias_names[key] = set()
210
+ return (key,)
211
+
212
+ def _visit(
213
+ self,
214
+ path: Path,
215
+ state: ResolveState,
216
+ stack: tuple[Path, ...],
217
+ root_format: str | None,
218
+ root_version: str | None,
219
+ ) -> ModelDocument:
220
+ canonical = path.resolve()
221
+ if canonical in stack:
222
+ cycle = " → ".join(item.name for item in (*stack, canonical))
223
+ raise DocumentError("EG006", canonical, f"cyclic import: {cycle}")
224
+ if len(stack) > MAX_IMPORT_DEPTH:
225
+ raise DocumentError("EG006", canonical, "import depth exceeds 10")
226
+ cached = state.cache.get(canonical)
227
+ if cached is not None:
228
+ self._ensure_compatible(cached, root_format, root_version, canonical)
229
+ return cached
230
+
231
+ document = self.parser.parse_document(canonical)
232
+ self._ensure_compatible(document, root_format, root_version, canonical)
233
+ state.cache[canonical] = document
234
+ closure = {canonical}
235
+ aliases: dict[str, Path] = {}
236
+ aliases_seen: set[str] = set()
237
+
238
+ for spec in document.imports:
239
+ resolved = self._resolve_import_path(document, spec, state)
240
+ if spec.kind == "resource":
241
+ self._register_resource(spec, resolved, aliases_seen, state)
242
+ continue
243
+ alias = spec.alias or resolved.stem
244
+ if alias in state.resource_aliases:
245
+ raise DocumentError("EG032", canonical, f"resource alias is already taken: {alias}")
246
+ if alias in aliases_seen:
247
+ raise DocumentError("EG025", canonical, f"duplicate import alias: {alias}")
248
+ aliases_seen.add(alias)
249
+ aliases[alias] = resolved
250
+ imported = self._visit(
251
+ resolved,
252
+ state,
253
+ (*stack, canonical),
254
+ root_format or document.format,
255
+ root_version or document.version,
256
+ )
257
+ self._ensure_compatible(imported, document.format, document.version, resolved)
258
+ closure |= state.closures[resolved]
259
+
260
+ state.closures[canonical] = closure
261
+ state.aliases[canonical] = aliases
262
+ state.alias_names[canonical] = aliases_seen
263
+ state.order.append(canonical)
264
+ return document
265
+
266
+ def _ensure_compatible(
267
+ self,
268
+ document: ModelDocument,
269
+ root_format: str | None,
270
+ root_version: str | None,
271
+ path: Path,
272
+ ) -> None:
273
+ if root_format is not None and document.format != root_format:
274
+ raise DocumentError("EG007", path, "imported document has a different format")
275
+ if root_version is not None and document.version != root_version:
276
+ raise DocumentError("EG008", path, "imported document has a different version")
277
+
278
+ def _resolve_import_path(
279
+ self,
280
+ document: ModelDocument,
281
+ spec: ImportSpec,
282
+ state: ResolveState,
283
+ ) -> Path:
284
+ raw = Path(spec.path)
285
+ if raw.is_absolute():
286
+ raise DocumentError(
287
+ "EG022", document.path, f"absolute import is not allowed: {spec.path}"
288
+ )
289
+ resolved = (document.path.parent / raw).resolve()
290
+ if not state.allow_outside and not resolved.is_relative_to(state.project_root):
291
+ raise DocumentError(
292
+ "EG022", document.path, f"import outside the project root: {spec.path}"
293
+ )
294
+ if not resolved.is_file():
295
+ raise DocumentError("EG000", document.path, f"import not found: {spec.path}")
296
+ return resolved
297
+
298
+ def _register_resource(
299
+ self,
300
+ spec: ImportSpec,
301
+ resolved: Path,
302
+ aliases_seen: set[str],
303
+ state: ResolveState,
304
+ ) -> None:
305
+ alias = spec.alias
306
+ if not alias:
307
+ raise DocumentError("EG025", resolved, "a resource import requires an alias")
308
+ if alias in state.resource_aliases:
309
+ raise DocumentError("EG032", resolved, f"resource alias is already taken: {alias}")
310
+ if alias in aliases_seen or alias in state.resources:
311
+ raise DocumentError("EG025", resolved, f"duplicate import alias: {alias}")
312
+ aliases_seen.add(alias)
313
+ try:
314
+ content = resolved.read_bytes()
315
+ except OSError as exc:
316
+ raise DocumentError("EG000", resolved, f"failed to read resource: {exc}") from exc
317
+ state.resources[alias] = ImportedFile(name=alias, path=resolved, content=content)
318
+
319
+ def _resolve_extends(self, state: ResolveState) -> dict[Path, dict[str, Entity]]:
320
+ memo: dict[tuple[Path, str], Entity] = {}
321
+ result: dict[Path, dict[str, Entity]] = {path: {} for path in state.order}
322
+ for path in state.order:
323
+ for entity in state.cache[path].model.entities:
324
+ result[path][entity.name] = self._resolve_entity(path, entity, state, memo, ())
325
+ return result
326
+
327
+ def _resolve_entity(
328
+ self,
329
+ doc_path: Path,
330
+ entity: Entity,
331
+ state: ResolveState,
332
+ memo: dict[tuple[Path, str], Entity],
333
+ stack: tuple[tuple[Path, str], ...],
334
+ ) -> Entity:
335
+ key = (doc_path, entity.name)
336
+ if key in memo:
337
+ return memo[key]
338
+ if key in stack:
339
+ raise DocumentError("EG009", doc_path, f"extends cycle: {entity.name}")
340
+ if len(stack) > MAX_EXTENDS_DEPTH:
341
+ raise DocumentError("EG009", doc_path, "extends depth exceeds 10")
342
+ if entity.extends is None:
343
+ memo[key] = entity
344
+ return entity
345
+
346
+ parent_path, parent = self._find_parent(doc_path, entity.extends, state)
347
+ resolved_parent = self._resolve_entity(
348
+ parent_path,
349
+ parent,
350
+ state,
351
+ memo,
352
+ (*stack, key),
353
+ )
354
+ if len({field.name for field in entity.fields}) != len(entity.fields):
355
+ raise DocumentError("EG003", doc_path, f"duplicate field in {entity.name}")
356
+ overrides = {field.name: field for field in entity.fields}
357
+ fields = tuple(overrides.pop(field.name, field) for field in resolved_parent.fields)
358
+ fields += tuple(field for field in entity.fields if field.name in overrides)
359
+ merged = entity.model_copy(update={"fields": fields, "extends": None})
360
+ memo[key] = merged
361
+ return merged
362
+
363
+ def _find_parent(
364
+ self,
365
+ doc_path: Path,
366
+ extends: str | ExtendsRef,
367
+ state: ResolveState,
368
+ ) -> tuple[Path, Entity]:
369
+ if isinstance(extends, ExtendsRef):
370
+ target = state.aliases.get(doc_path, {}).get(extends.import_)
371
+ if target is None:
372
+ raise DocumentError("EG009", doc_path, f"unknown import alias: {extends.import_}")
373
+ for entity in state.cache[target].model.entities:
374
+ if entity.name == extends.entity:
375
+ return target, entity
376
+ raise DocumentError(
377
+ "EG009",
378
+ doc_path,
379
+ f"entity {extends.entity} not found in import {extends.import_}",
380
+ )
381
+
382
+ candidates: list[tuple[Path, Entity]] = []
383
+ for path in state.closures[doc_path]:
384
+ for entity in state.cache[path].model.entities:
385
+ if entity.name == extends:
386
+ candidates.append((path, entity))
387
+ if not candidates:
388
+ raise DocumentError("EG009", doc_path, f"extends parent not found: {extends}")
389
+ if len(candidates) > 1:
390
+ raise DocumentError("EG009", doc_path, f"ambiguous extends: {extends}")
391
+ return candidates[0]
392
+
393
+
394
+ def _unit_refs(
395
+ sources: list[Path],
396
+ groups: list[SourceGroup] | None,
397
+ ) -> list[tuple[SourceGroup | None, ModelUnit]]:
398
+ if groups is None:
399
+ return [(None, ModelUnit(paths=(source.resolve(),))) for source in sources]
400
+ result: list[tuple[SourceGroup | None, ModelUnit]] = []
401
+ for group in groups:
402
+ units = group.units or (ModelUnit(paths=group.paths),)
403
+ for unit in units:
404
+ if unit.paths:
405
+ result.append((group, unit))
406
+ return result
407
+
408
+
409
+ __all__ = ["Composition", "CompositionResult"]
410
+
411
+
412
+ def _selected(
413
+ requested: tuple[str, ...] | None,
414
+ available: list[str],
415
+ label: str,
416
+ path: Path | None,
417
+ diagnostics: list[Diagnostic],
418
+ ) -> set[str] | None:
419
+ if not requested:
420
+ return None
421
+ available_set = set(available)
422
+ for name in requested:
423
+ if name not in available_set:
424
+ diagnostics.append(
425
+ error(
426
+ "EG018",
427
+ f"unknown name in sources[].input.{label}: {name}",
428
+ file=path,
429
+ )
430
+ )
431
+ return set(requested)