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.
- eggpipe/__init__.py +129 -0
- eggpipe/cli/__init__.py +0 -0
- eggpipe/cli/main.py +710 -0
- eggpipe/config/__init__.py +0 -0
- eggpipe/config/app_config.py +35 -0
- eggpipe/config/logging.py +40 -0
- eggpipe/config/project_config.py +162 -0
- eggpipe/core/__init__.py +0 -0
- eggpipe/core/composition.py +431 -0
- eggpipe/core/discovery.py +566 -0
- eggpipe/core/document.py +32 -0
- eggpipe/core/format_registry.py +230 -0
- eggpipe/core/layers.py +385 -0
- eggpipe/core/model.py +308 -0
- eggpipe/core/naming.py +36 -0
- eggpipe/core/parser.py +96 -0
- eggpipe/core/paths.py +12 -0
- eggpipe/core/provenance.py +52 -0
- eggpipe/core/slicing.py +110 -0
- eggpipe/core/validator.py +518 -0
- eggpipe/engine/__init__.py +0 -0
- eggpipe/engine/artifact_writer.py +741 -0
- eggpipe/engine/engine.py +679 -0
- eggpipe/engine/plugin_loader.py +215 -0
- eggpipe/engine/policy.py +27 -0
- eggpipe/engine/report.py +90 -0
- eggpipe/interfaces/__init__.py +0 -0
- eggpipe/interfaces/diagnostics.py +129 -0
- eggpipe/interfaces/formats.py +122 -0
- eggpipe/interfaces/generator.py +235 -0
- eggpipe/py.typed +0 -0
- eggpipe/templating.py +84 -0
- eggpipe/utils/__init__.py +0 -0
- eggpipe/utils/exceptions.py +65 -0
- eggpipe/utils/yaml.py +44 -0
- eggpipe/version.py +3 -0
- eggpipe/watch/__init__.py +0 -0
- eggpipe/watch/watcher.py +168 -0
- eggpipe-0.3.0.dist-info/METADATA +251 -0
- eggpipe-0.3.0.dist-info/RECORD +43 -0
- eggpipe-0.3.0.dist-info/WHEEL +4 -0
- eggpipe-0.3.0.dist-info/entry_points.txt +2 -0
- eggpipe-0.3.0.dist-info/licenses/LICENSE +21 -0
|
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
|
+
]
|
eggpipe/core/__init__.py
ADDED
|
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)
|