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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Document format registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from eggpipe.core.model import FormatSchema
|
|
10
|
+
from eggpipe.interfaces.formats import EntityModelFormat, FormatPlugin
|
|
11
|
+
from eggpipe.utils.exceptions import (
|
|
12
|
+
FormatConflictError,
|
|
13
|
+
FormatInheritanceError,
|
|
14
|
+
UnknownFormatError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
MAX_FORMAT_DEPTH = 10
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class FormatRegistryInfo:
|
|
22
|
+
name: str
|
|
23
|
+
versions: tuple[str, ...]
|
|
24
|
+
primitive_types: frozenset[str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class _FormatContract:
|
|
29
|
+
plugin: FormatPlugin
|
|
30
|
+
names: tuple[str, ...]
|
|
31
|
+
versions: tuple[str, ...]
|
|
32
|
+
schemas: dict[str, FormatSchema]
|
|
33
|
+
primitives: frozenset[str]
|
|
34
|
+
extends: str | None
|
|
35
|
+
base_versions: dict[str, str]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
return self.names[0]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FormatRegistry:
|
|
43
|
+
"""Built-in entity-model plus formats from plugins."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, *, include_builtin: bool = True) -> None:
|
|
46
|
+
self._formats: dict[str, _FormatContract] = {}
|
|
47
|
+
self._validated = False
|
|
48
|
+
if include_builtin:
|
|
49
|
+
self.register(EntityModelFormat())
|
|
50
|
+
|
|
51
|
+
def register(self, plugin: FormatPlugin) -> None:
|
|
52
|
+
contract = _contract(plugin)
|
|
53
|
+
for name in contract.names:
|
|
54
|
+
if name == "entity-model" and not isinstance(plugin, EntityModelFormat):
|
|
55
|
+
raise FormatConflictError(
|
|
56
|
+
"entity-model is reserved by the core and cannot be overridden"
|
|
57
|
+
)
|
|
58
|
+
if name in self._formats:
|
|
59
|
+
raise FormatConflictError(f"format is already registered: {name}")
|
|
60
|
+
for name in contract.names:
|
|
61
|
+
self._formats[name] = contract
|
|
62
|
+
self._validated = False
|
|
63
|
+
|
|
64
|
+
def get(self, name: str) -> FormatPlugin:
|
|
65
|
+
self.validate()
|
|
66
|
+
try:
|
|
67
|
+
return self._formats[name].plugin
|
|
68
|
+
except KeyError as exc:
|
|
69
|
+
raise UnknownFormatError(f"unknown format: {name}") from exc
|
|
70
|
+
|
|
71
|
+
def accepts(self, format_name: str, accepted: set[str] | None) -> bool:
|
|
72
|
+
"""Check a format, following the extends chain."""
|
|
73
|
+
if accepted is None:
|
|
74
|
+
return True
|
|
75
|
+
self.validate()
|
|
76
|
+
current: str | None = format_name
|
|
77
|
+
seen: set[str] = set()
|
|
78
|
+
while current is not None and current not in seen:
|
|
79
|
+
if current in accepted:
|
|
80
|
+
return True
|
|
81
|
+
seen.add(current)
|
|
82
|
+
contract = self._formats.get(current)
|
|
83
|
+
if contract is None:
|
|
84
|
+
return False
|
|
85
|
+
current = contract.extends
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def list(self) -> list[FormatRegistryInfo]:
|
|
89
|
+
self.validate()
|
|
90
|
+
return [
|
|
91
|
+
FormatRegistryInfo(
|
|
92
|
+
name=name,
|
|
93
|
+
versions=contract.versions,
|
|
94
|
+
primitive_types=contract.primitives,
|
|
95
|
+
)
|
|
96
|
+
for name, contract in sorted(self._formats.items())
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def validate(self) -> None:
|
|
100
|
+
if self._validated:
|
|
101
|
+
return
|
|
102
|
+
contracts = {id(contract.plugin): contract for contract in self._formats.values()}
|
|
103
|
+
for contract in contracts.values():
|
|
104
|
+
if contract.extends is not None:
|
|
105
|
+
self._validate_inheritance(contract)
|
|
106
|
+
self._validated = True
|
|
107
|
+
|
|
108
|
+
def _validate_inheritance(self, contract: _FormatContract) -> None:
|
|
109
|
+
parent = self._formats.get(contract.extends or "")
|
|
110
|
+
if parent is None:
|
|
111
|
+
raise FormatInheritanceError(
|
|
112
|
+
f"{contract.name}: unknown base format {contract.extends!r}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
seen: set[int] = set()
|
|
116
|
+
current: _FormatContract | None = contract
|
|
117
|
+
depth = 0
|
|
118
|
+
while current is not None and current.extends is not None:
|
|
119
|
+
if id(current) in seen:
|
|
120
|
+
raise FormatInheritanceError(f"{contract.name}: format inheritance cycle")
|
|
121
|
+
seen.add(id(current))
|
|
122
|
+
current = self._formats.get(current.extends)
|
|
123
|
+
if current is None:
|
|
124
|
+
raise FormatInheritanceError(
|
|
125
|
+
f"{contract.name}: unknown base format {contract.extends!r}"
|
|
126
|
+
)
|
|
127
|
+
depth += 1
|
|
128
|
+
if depth > MAX_FORMAT_DEPTH:
|
|
129
|
+
raise FormatInheritanceError(
|
|
130
|
+
f"{contract.name}: inheritance depth exceeds {MAX_FORMAT_DEPTH}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
for version in contract.versions:
|
|
134
|
+
base_version = contract.base_versions[version]
|
|
135
|
+
if base_version not in parent.versions:
|
|
136
|
+
raise FormatInheritanceError(
|
|
137
|
+
f"{contract.name} {version}: base version {base_version} is not "
|
|
138
|
+
f"supported by {contract.extends}"
|
|
139
|
+
)
|
|
140
|
+
own_schema = contract.schemas[version]
|
|
141
|
+
parent_schema = parent.schemas[base_version]
|
|
142
|
+
if not issubclass(own_schema.full_entity, parent_schema.full_entity):
|
|
143
|
+
raise FormatInheritanceError(
|
|
144
|
+
f"{contract.name} {version}: full_entity must inherit "
|
|
145
|
+
f"full_entity {contract.extends} {base_version}"
|
|
146
|
+
)
|
|
147
|
+
missing = set(parent.primitives) - set(contract.primitives)
|
|
148
|
+
if missing:
|
|
149
|
+
raise FormatInheritanceError(
|
|
150
|
+
f"{contract.name}: primitive_types must include the primitives of "
|
|
151
|
+
f"{contract.extends}: {', '.join(sorted(missing))}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _contract(plugin: FormatPlugin) -> _FormatContract:
|
|
156
|
+
names = getattr(plugin, "format_names", None)
|
|
157
|
+
if not isinstance(names, list) or not names:
|
|
158
|
+
raise FormatInheritanceError("format_names must be a non-empty list")
|
|
159
|
+
if len(set(names)) != len(names):
|
|
160
|
+
raise FormatInheritanceError("format_names contains duplicates")
|
|
161
|
+
if any(not isinstance(name, str) or not name for name in names):
|
|
162
|
+
raise FormatInheritanceError("format_names must contain strings")
|
|
163
|
+
|
|
164
|
+
versions = getattr(plugin, "supported_versions", None)
|
|
165
|
+
if not isinstance(versions, list) or not versions:
|
|
166
|
+
raise FormatInheritanceError(f"{names[0]}: supported_versions must be a non-empty list")
|
|
167
|
+
if any(not isinstance(version, str) or not version for version in versions):
|
|
168
|
+
raise FormatInheritanceError(f"{names[0]}: supported_versions must contain strings")
|
|
169
|
+
if len(set(versions)) != len(versions):
|
|
170
|
+
raise FormatInheritanceError(f"{names[0]}: supported_versions contains duplicates")
|
|
171
|
+
|
|
172
|
+
schemas = getattr(plugin, "schemas", None)
|
|
173
|
+
if not isinstance(schemas, dict):
|
|
174
|
+
raise FormatInheritanceError(f"{names[0]}: schemas must be a mapping")
|
|
175
|
+
if set(schemas) != set(versions):
|
|
176
|
+
raise FormatInheritanceError(f"{names[0]}: schemas keys must match supported_versions")
|
|
177
|
+
for version in versions:
|
|
178
|
+
schema = schemas[version]
|
|
179
|
+
if not isinstance(schema, FormatSchema):
|
|
180
|
+
raise FormatInheritanceError(f"{names[0]}: schemas[{version!r}] must be a FormatSchema")
|
|
181
|
+
if not _is_model(schema.layer_entity):
|
|
182
|
+
raise FormatInheritanceError(
|
|
183
|
+
f"{names[0]}: schemas[{version!r}].layer_entity must be a BaseModel"
|
|
184
|
+
)
|
|
185
|
+
if not _is_model(schema.full_entity):
|
|
186
|
+
raise FormatInheritanceError(
|
|
187
|
+
f"{names[0]}: schemas[{version!r}].full_entity must be a BaseModel"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
primitives = getattr(plugin, "primitive_types", None)
|
|
191
|
+
if not isinstance(primitives, (set, frozenset)) or any(
|
|
192
|
+
not isinstance(item, str) for item in primitives
|
|
193
|
+
):
|
|
194
|
+
raise FormatInheritanceError(f"{names[0]}: primitive_types must be a set of strings")
|
|
195
|
+
|
|
196
|
+
extends = getattr(plugin, "extends", None)
|
|
197
|
+
if extends is not None and (not isinstance(extends, str) or not extends):
|
|
198
|
+
raise FormatInheritanceError(f"{names[0]}: extends must be a non-empty string")
|
|
199
|
+
base_versions = getattr(plugin, "base_versions", None)
|
|
200
|
+
if not isinstance(base_versions, dict):
|
|
201
|
+
raise FormatInheritanceError(f"{names[0]}: base_versions must be a mapping")
|
|
202
|
+
if extends is None:
|
|
203
|
+
if base_versions:
|
|
204
|
+
raise FormatInheritanceError(f"{names[0]}: base_versions is only allowed with extends")
|
|
205
|
+
else:
|
|
206
|
+
if extends in names:
|
|
207
|
+
raise FormatInheritanceError(f"{names[0]}: a format cannot extend itself")
|
|
208
|
+
if set(base_versions) != set(versions):
|
|
209
|
+
raise FormatInheritanceError(
|
|
210
|
+
f"{names[0]}: base_versions must contain an entry for every version"
|
|
211
|
+
)
|
|
212
|
+
if any(not isinstance(value, str) or not value for value in base_versions.values()):
|
|
213
|
+
raise FormatInheritanceError(f"{names[0]}: versions in base_versions must be strings")
|
|
214
|
+
|
|
215
|
+
return _FormatContract(
|
|
216
|
+
plugin=plugin,
|
|
217
|
+
names=tuple(names),
|
|
218
|
+
versions=tuple(versions),
|
|
219
|
+
schemas=dict(schemas),
|
|
220
|
+
primitives=frozenset(primitives),
|
|
221
|
+
extends=extends,
|
|
222
|
+
base_versions=dict(base_versions),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _is_model(value: object) -> bool:
|
|
227
|
+
return isinstance(value, type) and issubclass(value, BaseModel)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
__all__ = ["FormatRegistry", "FormatRegistryInfo"]
|
eggpipe/core/layers.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Build a model unit from format layers (ADR-0008)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Generic, Protocol, TypeVar
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ValidationError
|
|
11
|
+
|
|
12
|
+
from eggpipe.core.document import ModelDocument
|
|
13
|
+
from eggpipe.core.model import CustomType, Entity, EnumDef, FieldDef, SourceModel
|
|
14
|
+
from eggpipe.interfaces.diagnostics import Diagnostic, warning
|
|
15
|
+
from eggpipe.interfaces.formats import FormatPlugin
|
|
16
|
+
from eggpipe.utils.exceptions import DocumentError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FormatLookup(Protocol):
|
|
20
|
+
def get(self, name: str) -> FormatPlugin: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class MergeChange(Generic[T]):
|
|
28
|
+
value: T
|
|
29
|
+
changed: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_MISSING = object()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class LayerMergeResult:
|
|
37
|
+
document: ModelDocument
|
|
38
|
+
diagnostics: list[Diagnostic]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def merge_model_layers(
|
|
42
|
+
documents: list[ModelDocument],
|
|
43
|
+
registry: FormatLookup,
|
|
44
|
+
) -> LayerMergeResult:
|
|
45
|
+
"""Validate and additively merge the layers of one model."""
|
|
46
|
+
if not documents:
|
|
47
|
+
raise DocumentError("EG027", None, "empty model")
|
|
48
|
+
layers = _ordered_layers(documents, registry)
|
|
49
|
+
diagnostics: list[Diagnostic] = []
|
|
50
|
+
|
|
51
|
+
base_doc = layers[0]
|
|
52
|
+
base_plugin = registry.get(base_doc.format)
|
|
53
|
+
_validate_entity(
|
|
54
|
+
base_plugin,
|
|
55
|
+
base_doc,
|
|
56
|
+
"full_entity",
|
|
57
|
+
_entity(base_doc),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
merged = _entity(base_doc)
|
|
61
|
+
enums = list(base_doc.model.enums)
|
|
62
|
+
types = list(base_doc.model.types)
|
|
63
|
+
metadata = dict(base_doc.model.metadata)
|
|
64
|
+
document_metadata = dict(base_doc.metadata)
|
|
65
|
+
layer_sources = [base_doc.path]
|
|
66
|
+
|
|
67
|
+
for index, delta_doc in enumerate(layers[1:], start=1):
|
|
68
|
+
delta_plugin = registry.get(delta_doc.format)
|
|
69
|
+
_validate_layer_chain(layers[index - 1], delta_doc, delta_plugin)
|
|
70
|
+
_validate_entity(delta_plugin, delta_doc, "layer_entity", _entity(delta_doc))
|
|
71
|
+
|
|
72
|
+
delta = _entity(delta_doc)
|
|
73
|
+
if delta.name != merged.name:
|
|
74
|
+
raise DocumentError(
|
|
75
|
+
"EG029",
|
|
76
|
+
delta_doc.path,
|
|
77
|
+
f"layer {delta_doc.format} describes {delta.name}, expected {merged.name}",
|
|
78
|
+
)
|
|
79
|
+
merged_change = _merge_entity(merged, delta, delta_doc.path)
|
|
80
|
+
enums_change = _merge_enums(enums, delta_doc.model.enums, delta_doc.path)
|
|
81
|
+
types_change = _merge_types(types, delta_doc.model.types, delta_doc.path)
|
|
82
|
+
metadata_change = _merge_metadata(
|
|
83
|
+
metadata,
|
|
84
|
+
delta_doc.model.metadata,
|
|
85
|
+
delta_doc.path,
|
|
86
|
+
)
|
|
87
|
+
document_metadata_change = _merge_metadata(
|
|
88
|
+
document_metadata,
|
|
89
|
+
delta_doc.metadata,
|
|
90
|
+
delta_doc.path,
|
|
91
|
+
path="document.metadata",
|
|
92
|
+
)
|
|
93
|
+
merged = merged_change.value
|
|
94
|
+
enums = enums_change.value
|
|
95
|
+
types = types_change.value
|
|
96
|
+
metadata = metadata_change.value
|
|
97
|
+
document_metadata = document_metadata_change.value
|
|
98
|
+
added = any(
|
|
99
|
+
change.changed
|
|
100
|
+
for change in (
|
|
101
|
+
merged_change,
|
|
102
|
+
enums_change,
|
|
103
|
+
types_change,
|
|
104
|
+
metadata_change,
|
|
105
|
+
document_metadata_change,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
layer_sources.append(delta_doc.path)
|
|
109
|
+
if not added:
|
|
110
|
+
diagnostics.append(
|
|
111
|
+
warning(
|
|
112
|
+
"EG035",
|
|
113
|
+
f"delta {delta_doc.format} adds nothing",
|
|
114
|
+
path=str(delta_doc.path),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
derived_doc = layers[-1]
|
|
119
|
+
derived_plugin = registry.get(derived_doc.format)
|
|
120
|
+
final = merged.model_copy(
|
|
121
|
+
update={
|
|
122
|
+
"format": derived_doc.format,
|
|
123
|
+
"format_version": derived_doc.version,
|
|
124
|
+
"layer_sources": tuple(layer_sources),
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
_validate_entity(derived_plugin, derived_doc, "full_entity", final)
|
|
128
|
+
model = SourceModel(
|
|
129
|
+
name=base_doc.model.name,
|
|
130
|
+
description=base_doc.model.description,
|
|
131
|
+
entities=(final,),
|
|
132
|
+
enums=tuple(enums),
|
|
133
|
+
types=tuple(types),
|
|
134
|
+
metadata=metadata,
|
|
135
|
+
)
|
|
136
|
+
document = ModelDocument(
|
|
137
|
+
format=derived_doc.format,
|
|
138
|
+
version=derived_doc.version,
|
|
139
|
+
path=base_doc.path,
|
|
140
|
+
imports=(),
|
|
141
|
+
model=model,
|
|
142
|
+
metadata=document_metadata,
|
|
143
|
+
)
|
|
144
|
+
return LayerMergeResult(document=document, diagnostics=diagnostics)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ordered_layers(
|
|
148
|
+
documents: list[ModelDocument],
|
|
149
|
+
registry: FormatLookup,
|
|
150
|
+
) -> list[ModelDocument]:
|
|
151
|
+
by_format: dict[str, ModelDocument] = {}
|
|
152
|
+
plugins: dict[str, FormatPlugin] = {}
|
|
153
|
+
for document in documents:
|
|
154
|
+
if len(document.model.entities) != 1:
|
|
155
|
+
raise DocumentError(
|
|
156
|
+
"EG031", document.path, "a layer file must contain exactly one entity"
|
|
157
|
+
)
|
|
158
|
+
if document.imports:
|
|
159
|
+
raise DocumentError("EG029", document.path, "imports are not allowed in a layer file")
|
|
160
|
+
entity = document.model.entities[0]
|
|
161
|
+
if entity.extends is not None:
|
|
162
|
+
raise DocumentError("EG029", document.path, "extends is not allowed in a layer file")
|
|
163
|
+
if document.format in by_format:
|
|
164
|
+
raise DocumentError(
|
|
165
|
+
"EG029",
|
|
166
|
+
document.path,
|
|
167
|
+
f"multiple files of the same format: {document.format}",
|
|
168
|
+
)
|
|
169
|
+
by_format[document.format] = document
|
|
170
|
+
plugins[document.format] = registry.get(document.format)
|
|
171
|
+
|
|
172
|
+
roots = [
|
|
173
|
+
name
|
|
174
|
+
for name, plugin in plugins.items()
|
|
175
|
+
if plugin.extends is None or plugin.extends not in plugins
|
|
176
|
+
]
|
|
177
|
+
if len(roots) != 1:
|
|
178
|
+
raise DocumentError("EG029", documents[0].path, "layer formats do not form a single chain")
|
|
179
|
+
|
|
180
|
+
ordered = [roots[0]]
|
|
181
|
+
current = roots[0]
|
|
182
|
+
while True:
|
|
183
|
+
children = [name for name, plugin in plugins.items() if plugin.extends == current]
|
|
184
|
+
if len(children) > 1:
|
|
185
|
+
raise DocumentError(
|
|
186
|
+
"EG029",
|
|
187
|
+
documents[0].path,
|
|
188
|
+
"layer formats form a branch",
|
|
189
|
+
)
|
|
190
|
+
if not children:
|
|
191
|
+
break
|
|
192
|
+
ordered.append(children[0])
|
|
193
|
+
current = children[0]
|
|
194
|
+
if set(ordered) != set(by_format):
|
|
195
|
+
raise DocumentError(
|
|
196
|
+
"EG029", documents[0].path, "layer formats are not connected by inheritance"
|
|
197
|
+
)
|
|
198
|
+
return [by_format[name] for name in ordered]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _validate_layer_chain(
|
|
202
|
+
parent: ModelDocument,
|
|
203
|
+
child: ModelDocument,
|
|
204
|
+
child_plugin: FormatPlugin,
|
|
205
|
+
) -> None:
|
|
206
|
+
expected = child_plugin.base_versions.get(child.version)
|
|
207
|
+
if expected is None:
|
|
208
|
+
raise DocumentError(
|
|
209
|
+
"EG034",
|
|
210
|
+
child.path,
|
|
211
|
+
f"{child.format} {child.version}: base_versions is missing",
|
|
212
|
+
)
|
|
213
|
+
if expected != parent.version:
|
|
214
|
+
raise DocumentError(
|
|
215
|
+
"EG034",
|
|
216
|
+
child.path,
|
|
217
|
+
f"{child.format} {child.version} requires {parent.format} {expected}, "
|
|
218
|
+
f"but the layer has {parent.version}",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _validate_entity(
|
|
223
|
+
plugin: FormatPlugin,
|
|
224
|
+
document: ModelDocument,
|
|
225
|
+
schema_name: str,
|
|
226
|
+
entity: Entity,
|
|
227
|
+
) -> None:
|
|
228
|
+
schema = plugin.schemas.get(document.version)
|
|
229
|
+
if schema is None:
|
|
230
|
+
raise DocumentError(
|
|
231
|
+
"EG034",
|
|
232
|
+
document.path,
|
|
233
|
+
f"{document.format} {document.version}: schema is missing",
|
|
234
|
+
)
|
|
235
|
+
model: type[BaseModel] = getattr(schema, schema_name)
|
|
236
|
+
try:
|
|
237
|
+
model.model_validate(entity, from_attributes=True)
|
|
238
|
+
except ValidationError as exc:
|
|
239
|
+
raise DocumentError(
|
|
240
|
+
"EG029",
|
|
241
|
+
document.path,
|
|
242
|
+
f"{document.format} {document.version}: {schema_name}: {exc}",
|
|
243
|
+
) from exc
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _entity(document: ModelDocument) -> Entity:
|
|
247
|
+
return document.model.entities[0]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _merge_entity(
|
|
251
|
+
base: Entity,
|
|
252
|
+
delta: Entity,
|
|
253
|
+
source: Path,
|
|
254
|
+
) -> MergeChange[Entity]:
|
|
255
|
+
added = False
|
|
256
|
+
values: dict[str, Any] = {}
|
|
257
|
+
for name in ("description", "version", "updated_at"):
|
|
258
|
+
left = getattr(base, name)
|
|
259
|
+
right = getattr(delta, name)
|
|
260
|
+
if right is None:
|
|
261
|
+
continue
|
|
262
|
+
if left is None:
|
|
263
|
+
values[name] = right
|
|
264
|
+
added = True
|
|
265
|
+
elif left != right:
|
|
266
|
+
raise DocumentError(
|
|
267
|
+
"EG029",
|
|
268
|
+
source,
|
|
269
|
+
f"scalar conflict for {name}: {left!r} and {right!r}",
|
|
270
|
+
)
|
|
271
|
+
metadata_change = _merge_metadata(base.metadata, delta.metadata, source)
|
|
272
|
+
if metadata_change.changed:
|
|
273
|
+
values["metadata"] = metadata_change.value
|
|
274
|
+
added = True
|
|
275
|
+
|
|
276
|
+
fields = list(base.fields)
|
|
277
|
+
by_name = {field.name: index for index, field in enumerate(fields)}
|
|
278
|
+
for field in delta.fields:
|
|
279
|
+
index = by_name.get(field.name)
|
|
280
|
+
if index is None:
|
|
281
|
+
fields.append(field.model_copy(update={"source": source}))
|
|
282
|
+
by_name[field.name] = len(fields) - 1
|
|
283
|
+
added = True
|
|
284
|
+
continue
|
|
285
|
+
field_change = _merge_field(fields[index], field, source)
|
|
286
|
+
fields[index] = field_change.value
|
|
287
|
+
added |= field_change.changed
|
|
288
|
+
if added or len(fields) != len(base.fields):
|
|
289
|
+
values["fields"] = tuple(fields)
|
|
290
|
+
return MergeChange(base.model_copy(update=values), added)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _merge_field(base: FieldDef, delta: FieldDef, source: Path) -> MergeChange[FieldDef]:
|
|
294
|
+
left = base.model_dump(exclude={"metadata"})
|
|
295
|
+
right = delta.model_dump(exclude={"metadata"})
|
|
296
|
+
if left != right:
|
|
297
|
+
raise DocumentError(
|
|
298
|
+
"EG029",
|
|
299
|
+
source,
|
|
300
|
+
f"overriding field {base.name} is not allowed",
|
|
301
|
+
)
|
|
302
|
+
metadata_change = _merge_metadata(base.metadata, delta.metadata, source)
|
|
303
|
+
if not metadata_change.changed:
|
|
304
|
+
return MergeChange(base)
|
|
305
|
+
return MergeChange(
|
|
306
|
+
base.model_copy(update={"metadata": metadata_change.value}),
|
|
307
|
+
True,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _merge_enums(
|
|
312
|
+
base: list[EnumDef],
|
|
313
|
+
delta: tuple[EnumDef, ...],
|
|
314
|
+
source: Path,
|
|
315
|
+
) -> MergeChange[list[EnumDef]]:
|
|
316
|
+
return _merge_append(base, delta, source, label="enum", key=lambda item: item.name)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _merge_types(
|
|
320
|
+
base: list[CustomType],
|
|
321
|
+
delta: tuple[CustomType, ...],
|
|
322
|
+
source: Path,
|
|
323
|
+
) -> MergeChange[list[CustomType]]:
|
|
324
|
+
return _merge_append(base, delta, source, label="type", key=lambda item: item.name)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _merge_append(
|
|
328
|
+
base: list[T],
|
|
329
|
+
delta: tuple[T, ...],
|
|
330
|
+
source: Path,
|
|
331
|
+
*,
|
|
332
|
+
label: str,
|
|
333
|
+
key: Callable[[T], str],
|
|
334
|
+
) -> MergeChange[list[T]]:
|
|
335
|
+
by_name = {key(item): item for item in base}
|
|
336
|
+
result = list(base)
|
|
337
|
+
added = False
|
|
338
|
+
for item in delta:
|
|
339
|
+
name = key(item)
|
|
340
|
+
existing = by_name.get(name)
|
|
341
|
+
if existing is None:
|
|
342
|
+
result.append(item)
|
|
343
|
+
by_name[name] = item
|
|
344
|
+
added = True
|
|
345
|
+
elif existing != item:
|
|
346
|
+
raise DocumentError("EG029", source, f"overriding {label} {name} is not allowed")
|
|
347
|
+
return MergeChange(result, added)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _merge_metadata(
|
|
351
|
+
base: dict[str, Any],
|
|
352
|
+
delta: dict[str, Any],
|
|
353
|
+
source: Path,
|
|
354
|
+
*,
|
|
355
|
+
path: str = "metadata",
|
|
356
|
+
) -> MergeChange[dict[str, Any]]:
|
|
357
|
+
result = dict(base)
|
|
358
|
+
changed = False
|
|
359
|
+
for key, value in delta.items():
|
|
360
|
+
change = _merge_value(result.get(key, _MISSING), value, source, f"{path}.{key}")
|
|
361
|
+
if change.changed:
|
|
362
|
+
result[key] = change.value
|
|
363
|
+
changed = True
|
|
364
|
+
return MergeChange(result, changed)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _merge_value(current: Any, incoming: Any, source: Path, path: str) -> MergeChange[Any]:
|
|
368
|
+
if current is _MISSING:
|
|
369
|
+
return MergeChange(incoming, True)
|
|
370
|
+
match current, incoming:
|
|
371
|
+
case dict(), dict():
|
|
372
|
+
return _merge_metadata(current, incoming, source, path=path)
|
|
373
|
+
case list(), list():
|
|
374
|
+
return MergeChange([*current, *incoming], bool(incoming))
|
|
375
|
+
case _ if current == incoming:
|
|
376
|
+
return MergeChange(current)
|
|
377
|
+
case _:
|
|
378
|
+
raise DocumentError(
|
|
379
|
+
"EG029",
|
|
380
|
+
source,
|
|
381
|
+
f"conflict at {path}: {current!r} and {incoming!r}",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
__all__ = ["FormatLookup", "LayerMergeResult", "merge_model_layers"]
|