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.
@@ -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"]