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/core/model.py ADDED
@@ -0,0 +1,308 @@
1
+ """Internal immutable core models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from datetime import date, datetime
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pydantic import (
13
+ BaseModel,
14
+ ConfigDict,
15
+ Field,
16
+ PrivateAttr,
17
+ field_serializer,
18
+ field_validator,
19
+ model_serializer,
20
+ model_validator,
21
+ )
22
+
23
+ PRIMITIVE_TYPES: frozenset[str] = frozenset(
24
+ {
25
+ "string",
26
+ "text",
27
+ "integer",
28
+ "bigint",
29
+ "boolean",
30
+ "float",
31
+ "date",
32
+ "datetime",
33
+ "uuid",
34
+ "json",
35
+ }
36
+ )
37
+
38
+
39
+ class _Unset:
40
+ """Sentinel for "value not set"."""
41
+
42
+ def __repr__(self) -> str:
43
+ return "UNSET"
44
+
45
+
46
+ UNSET = _Unset()
47
+
48
+ DefaultValue = str | int | float | bool | date | datetime | None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class FormatInfo:
53
+ """Concrete format and version."""
54
+
55
+ name: str
56
+ version: str
57
+
58
+
59
+ class MergePolicy(StrEnum):
60
+ """Merge policy for layer collections."""
61
+
62
+ KEYED = "keyed"
63
+ APPEND = "append"
64
+ UNION = "union"
65
+ ERROR = "error"
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class FormatSchema:
70
+ """Pydantic schemas for one layer and the final entity."""
71
+
72
+ layer_entity: type[BaseModel]
73
+ full_entity: type[BaseModel]
74
+
75
+
76
+ class EntityIdentity(BaseModel):
77
+ """Minimal entity identity model for a delta layer."""
78
+
79
+ name: str = Field(..., pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
80
+
81
+ model_config = ConfigDict(extra="forbid", frozen=True)
82
+
83
+
84
+ class FieldIdentity(BaseModel):
85
+ """Minimal field identity model for a delta layer."""
86
+
87
+ name: str = Field(..., pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*$")
88
+
89
+ model_config = ConfigDict(extra="forbid", frozen=True)
90
+
91
+
92
+ class Constraints(BaseModel):
93
+ min: int | float | date | datetime | None = None
94
+ max: int | float | date | datetime | None = None
95
+ min_length: int | None = None
96
+ max_length: int | None = None
97
+ pattern: str | None = None
98
+
99
+ model_config = ConfigDict(extra="forbid", frozen=True)
100
+
101
+
102
+ class FieldDef(BaseModel):
103
+ name: str = Field(..., pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*$")
104
+ type: str
105
+ description: str | None = None
106
+ required: bool = False
107
+ nullable: bool = False
108
+ default: DefaultValue | _Unset = UNSET
109
+ constraints: Constraints | None = None
110
+ metadata: dict[str, Any] = Field(default_factory=dict)
111
+ source: Path | None = Field(default=None, exclude=True, repr=False)
112
+
113
+ model_config = ConfigDict(
114
+ extra="forbid",
115
+ frozen=True,
116
+ arbitrary_types_allowed=True,
117
+ )
118
+
119
+ @field_serializer("default")
120
+ def _serialize_default(self, value: object, _info: object) -> object:
121
+ # UNSET is not serialized to JSON as a value; model_serializer removes the key.
122
+ return None if value is UNSET else value
123
+
124
+ @model_validator(mode="after")
125
+ def _coerce_temporal_default(self) -> FieldDef:
126
+ if self.type == "date" and isinstance(self.default, str):
127
+ try:
128
+ return self.model_copy(update={"default": date.fromisoformat(self.default)})
129
+ except ValueError:
130
+ return self
131
+ if self.type == "datetime" and isinstance(self.default, str):
132
+ try:
133
+ return self.model_copy(
134
+ update={"default": datetime.fromisoformat(self.default.replace("Z", "+00:00"))}
135
+ )
136
+ except ValueError:
137
+ return self
138
+ return self
139
+
140
+ @model_serializer(mode="wrap")
141
+ def _serialize(self, handler: Callable[[Any], dict[str, Any]]) -> dict[str, Any]:
142
+ data = handler(self)
143
+ if self.default is UNSET:
144
+ data.pop("default", None)
145
+ return data
146
+
147
+
148
+ class ExtendsRef(BaseModel):
149
+ import_: str = Field(alias="import")
150
+ entity: str
151
+
152
+ model_config = ConfigDict(
153
+ extra="forbid",
154
+ frozen=True,
155
+ populate_by_name=True,
156
+ )
157
+
158
+
159
+ class LayerEntity(EntityIdentity):
160
+ """Partial entity schema for a layer file."""
161
+
162
+ description: str | None = None
163
+ version: str | int | None = None
164
+ updated_at: date | datetime | None = None
165
+ extends: str | ExtendsRef | None = None
166
+ fields: tuple[FieldDef, ...] = ()
167
+ metadata: dict[str, Any] = Field(default_factory=dict)
168
+
169
+
170
+ class Entity(BaseModel):
171
+ name: str = Field(..., pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
172
+ description: str | None = None
173
+ version: str | int | None = None
174
+ updated_at: date | datetime | None = None
175
+ extends: str | ExtendsRef | None = None
176
+ fields: tuple[FieldDef, ...] = ()
177
+ metadata: dict[str, Any] = Field(default_factory=dict)
178
+ source: Path | None = Field(default=None, exclude=True, repr=False)
179
+ format: str | None = Field(default=None, exclude=True, repr=False)
180
+ format_version: str | None = Field(default=None, exclude=True, repr=False)
181
+ layer_sources: tuple[Path, ...] = Field(default=(), exclude=True, repr=False)
182
+
183
+ model_config = ConfigDict(extra="forbid", frozen=True)
184
+
185
+ @field_validator("updated_at", mode="before")
186
+ @classmethod
187
+ def _coerce_updated_at(cls, value: object) -> object:
188
+ # Pydantic smart union parses midnight ISO-datetime as date; preserve the type.
189
+ if isinstance(value, str) and ("T" in value or " " in value):
190
+ try:
191
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
192
+ except ValueError:
193
+ return value
194
+ return value
195
+
196
+
197
+ class EnumValue(BaseModel):
198
+ name: str = Field(..., pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
199
+ value: str | int | None = None
200
+ description: str | None = None
201
+
202
+ model_config = ConfigDict(extra="forbid", frozen=True)
203
+
204
+
205
+ class EnumDef(BaseModel):
206
+ name: str = Field(..., pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
207
+ description: str | None = None
208
+ values: tuple[EnumValue, ...] = ()
209
+ metadata: dict[str, Any] = Field(default_factory=dict)
210
+
211
+ model_config = ConfigDict(extra="forbid", frozen=True)
212
+
213
+
214
+ class CustomType(BaseModel):
215
+ name: str = Field(..., pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
216
+ description: str | None = None
217
+ base: str
218
+ constraints: Constraints | None = None
219
+ metadata: dict[str, Any] = Field(default_factory=dict)
220
+
221
+ model_config = ConfigDict(extra="forbid", frozen=True)
222
+
223
+
224
+ class _IndexedModel(BaseModel):
225
+ """Immutable model with name indexes."""
226
+
227
+ entities: tuple[Entity, ...] = ()
228
+ enums: tuple[EnumDef, ...] = ()
229
+ types: tuple[CustomType, ...] = ()
230
+
231
+ _entity_map: dict[str, Entity] = PrivateAttr(default_factory=dict)
232
+ _enum_map: dict[str, EnumDef] = PrivateAttr(default_factory=dict)
233
+ _type_map: dict[str, CustomType] = PrivateAttr(default_factory=dict)
234
+
235
+ model_config = ConfigDict(extra="forbid", frozen=True)
236
+
237
+ @model_validator(mode="after")
238
+ def _build_maps(self) -> _IndexedModel:
239
+ object.__setattr__(self, "_entity_map", {item.name: item for item in self.entities})
240
+ object.__setattr__(self, "_enum_map", {item.name: item for item in self.enums})
241
+ object.__setattr__(self, "_type_map", {item.name: item for item in self.types})
242
+ return self
243
+
244
+ def entity(self, name: str) -> Entity:
245
+ return self._entity_map[name]
246
+
247
+ def enum(self, name: str) -> EnumDef:
248
+ return self._enum_map[name]
249
+
250
+ def type(self, name: str) -> CustomType:
251
+ return self._type_map[name]
252
+
253
+ def resolve_type(self, field: FieldDef) -> Entity | EnumDef | CustomType | str:
254
+ if field.type in self._entity_map:
255
+ return self._entity_map[field.type]
256
+ if field.type in self._enum_map:
257
+ return self._enum_map[field.type]
258
+ if field.type in self._type_map:
259
+ return self._type_map[field.type]
260
+ return field.type
261
+
262
+
263
+ class ModelSlice(_IndexedModel):
264
+ @property
265
+ def formats(self) -> frozenset[FormatInfo]:
266
+ return frozenset(
267
+ FormatInfo(entity.format, entity.format_version)
268
+ for entity in self.entities
269
+ if entity.format is not None and entity.format_version is not None
270
+ )
271
+
272
+
273
+ class SourceModel(ModelSlice):
274
+ name: str | None = None
275
+ description: str | None = None
276
+ metadata: dict[str, Any] = Field(default_factory=dict)
277
+
278
+
279
+ class InputSelection(BaseModel):
280
+ """Model subset selection; None/empty means all."""
281
+
282
+ entities: tuple[str, ...] | None = None
283
+ enums: tuple[str, ...] | None = None
284
+ types: tuple[str, ...] | None = None
285
+
286
+ model_config = ConfigDict(extra="forbid", frozen=True)
287
+
288
+
289
+ __all__ = [
290
+ "PRIMITIVE_TYPES",
291
+ "UNSET",
292
+ "CustomType",
293
+ "DefaultValue",
294
+ "Entity",
295
+ "EntityIdentity",
296
+ "EnumDef",
297
+ "EnumValue",
298
+ "ExtendsRef",
299
+ "FieldDef",
300
+ "FieldIdentity",
301
+ "FormatInfo",
302
+ "FormatSchema",
303
+ "InputSelection",
304
+ "LayerEntity",
305
+ "MergePolicy",
306
+ "ModelSlice",
307
+ "SourceModel",
308
+ ]
eggpipe/core/naming.py ADDED
@@ -0,0 +1,36 @@
1
+ """Naming utilities; renaming the model remains a plugin decision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from enum import StrEnum
7
+
8
+
9
+ class NamingStyle(StrEnum):
10
+ SNAKE = "snake_case"
11
+ CAMEL = "camelCase"
12
+ PASCAL = "pascal_case"
13
+ KEBAB = "kebab-case"
14
+
15
+
16
+ def transform(name: str, style: NamingStyle) -> str:
17
+ """Deterministically convert a name to the requested style."""
18
+ words = _split_words(name)
19
+ if not words:
20
+ return name
21
+ if style is NamingStyle.SNAKE:
22
+ return "_".join(word.lower() for word in words)
23
+ if style is NamingStyle.KEBAB:
24
+ return "-".join(word.lower() for word in words)
25
+ if style is NamingStyle.PASCAL:
26
+ return "".join(word[:1].upper() + word[1:].lower() for word in words)
27
+ return words[0].lower() + "".join(word[:1].upper() + word[1:].lower() for word in words[1:])
28
+
29
+
30
+ def _split_words(name: str) -> list[str]:
31
+ value = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
32
+ value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", value)
33
+ return [part for part in re.split(r"[^A-Za-z0-9]+", value) if part]
34
+
35
+
36
+ __all__ = ["NamingStyle", "transform"]
eggpipe/core/parser.py ADDED
@@ -0,0 +1,96 @@
1
+ """Model document parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from eggpipe.core.document import ModelDocument
10
+ from eggpipe.core.format_registry import FormatRegistry
11
+ from eggpipe.core.model import SourceModel
12
+ from eggpipe.utils.exceptions import (
13
+ DocumentError,
14
+ UnknownFormatError,
15
+ )
16
+ from eggpipe.utils.yaml import YamlLoadError, load_yaml
17
+
18
+
19
+ def normalize_version(value: object) -> str | None:
20
+ """Normalize version: strings as-is, numbers to a string with one decimal place."""
21
+ if isinstance(value, bool) or value is None:
22
+ return None
23
+ if isinstance(value, str):
24
+ return value
25
+ if isinstance(value, int):
26
+ return f"{value}.0"
27
+ if isinstance(value, float):
28
+ return f"{value:.1f}"
29
+ return None
30
+
31
+
32
+ class Parser:
33
+ def __init__(self, registry: FormatRegistry | None = None) -> None:
34
+ self.registry = registry or FormatRegistry()
35
+
36
+ def parse_document(self, path: Path) -> ModelDocument:
37
+ try:
38
+ raw = load_yaml(path)
39
+ except YamlLoadError as exc:
40
+ raise DocumentError(exc.code, path, exc.message) from exc
41
+
42
+ format_name = raw.get("format")
43
+ if not isinstance(format_name, str) or not format_name:
44
+ raise DocumentError("EG007", path, "`format` is required")
45
+
46
+ try:
47
+ plugin = self.registry.get(format_name)
48
+ except UnknownFormatError as exc:
49
+ raise DocumentError("EG007", path, str(exc)) from exc
50
+
51
+ raw_version = raw.get("version")
52
+ if raw_version is None:
53
+ version = plugin.supported_versions[-1]
54
+ else:
55
+ version = normalize_version(raw_version)
56
+ if version is None:
57
+ raise DocumentError("EG008", path, "`version` must be a string or a number")
58
+ if version not in plugin.supported_versions:
59
+ raise DocumentError(
60
+ "EG008",
61
+ path,
62
+ f"{format_name} does not support version {version}",
63
+ )
64
+
65
+ raw["format"] = format_name
66
+ raw["version"] = version
67
+ try:
68
+ document = plugin.parse(raw, base_path=path.parent)
69
+ except DocumentError:
70
+ raise
71
+ except ValidationError as exc:
72
+ raise DocumentError("EG028", path, str(exc)) from exc
73
+ model = SourceModel(
74
+ name=document.model.name,
75
+ description=document.model.description,
76
+ metadata=document.model.metadata,
77
+ entities=tuple(
78
+ entity.model_copy(
79
+ update={
80
+ "source": path,
81
+ "format": format_name,
82
+ "format_version": version,
83
+ "fields": tuple(
84
+ field.model_copy(update={"source": path}) for field in entity.fields
85
+ ),
86
+ }
87
+ )
88
+ for entity in document.model.entities
89
+ ),
90
+ enums=document.model.enums,
91
+ types=document.model.types,
92
+ )
93
+ return document.model_copy(update={"path": path, "model": model})
94
+
95
+
96
+ __all__ = ["Parser", "normalize_version"]
eggpipe/core/paths.py ADDED
@@ -0,0 +1,12 @@
1
+ """Shared path filtering rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ IGNORED_PARTS = frozenset({".git", ".eggpipe", "__pycache__", ".venv", "node_modules"})
6
+
7
+
8
+ def is_ignored_part(part: str) -> bool:
9
+ return part.startswith(".") or part in IGNORED_PARTS
10
+
11
+
12
+ __all__ = ["IGNORED_PARTS", "is_ignored_part"]
@@ -0,0 +1,52 @@
1
+ """Source provenance: the last git commit of a file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Provenance:
13
+ path: Path
14
+ commit: str | None
15
+ date: datetime | None
16
+
17
+
18
+ def git_provenance(path: Path) -> Provenance | None:
19
+ """Return commit/date for a file, or None when git/history is unavailable."""
20
+ resolved = path.resolve()
21
+ try:
22
+ result = subprocess.run(
23
+ [
24
+ "git",
25
+ "-C",
26
+ str(resolved.parent),
27
+ "log",
28
+ "-1",
29
+ "--format=%H%n%cI",
30
+ "--",
31
+ resolved.name,
32
+ ],
33
+ capture_output=True,
34
+ text=True,
35
+ check=False,
36
+ timeout=5,
37
+ )
38
+ except (OSError, subprocess.TimeoutExpired):
39
+ return None
40
+ if result.returncode != 0:
41
+ return None
42
+ lines = [line for line in result.stdout.splitlines() if line]
43
+ if len(lines) < 2:
44
+ return None
45
+ try:
46
+ date = datetime.fromisoformat(lines[1].replace("Z", "+00:00"))
47
+ except ValueError:
48
+ date = None
49
+ return Provenance(path=resolved, commit=lines[0], date=date)
50
+
51
+
52
+ __all__ = ["Provenance", "git_provenance"]
@@ -0,0 +1,110 @@
1
+ """Build a closed model slice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from eggpipe.core.model import (
8
+ Entity,
9
+ InputSelection,
10
+ ModelSlice,
11
+ SourceModel,
12
+ )
13
+ from eggpipe.interfaces.diagnostics import Diagnostic, error
14
+
15
+
16
+ def select_slice(
17
+ model: SourceModel,
18
+ selection: InputSelection | None = None,
19
+ ) -> tuple[ModelSlice, list[Diagnostic]]:
20
+ """Select a subset and add the transitive closure over enums/types."""
21
+ if selection is None:
22
+ return (
23
+ ModelSlice(
24
+ entities=model.entities,
25
+ enums=model.enums,
26
+ types=model.types,
27
+ ),
28
+ [],
29
+ )
30
+
31
+ diagnostics: list[Diagnostic] = []
32
+ entity_names = _requested(
33
+ "entities",
34
+ [item.name for item in model.entities],
35
+ selection.entities,
36
+ diagnostics,
37
+ )
38
+ enum_names = _requested(
39
+ "enums",
40
+ [item.name for item in model.enums],
41
+ selection.enums,
42
+ diagnostics,
43
+ )
44
+ type_names = _requested(
45
+ "types",
46
+ [item.name for item in model.types],
47
+ selection.types,
48
+ diagnostics,
49
+ )
50
+
51
+ entities = tuple(
52
+ item for item in model.entities if entity_names is None or item.name in entity_names
53
+ )
54
+ enum_map = {item.name: item for item in model.enums}
55
+ type_map = {item.name: item for item in model.types}
56
+
57
+ selected_enums = {item.name for item in model.enums} if enum_names is None else set(enum_names)
58
+ selected_types = {item.name for item in model.types} if type_names is None else set(type_names)
59
+
60
+ changed = True
61
+ while changed:
62
+ changed = False
63
+ for name in _referenced(entities, model, selected_types):
64
+ if name in enum_map and name not in selected_enums:
65
+ selected_enums.add(name)
66
+ changed = True
67
+ elif name in type_map and name not in selected_types:
68
+ selected_types.add(name)
69
+ changed = True
70
+
71
+ enums = tuple(item for item in model.enums if item.name in selected_enums)
72
+ types = tuple(item for item in model.types if item.name in selected_types)
73
+ return ModelSlice(entities=entities, enums=enums, types=types), diagnostics
74
+
75
+
76
+ def _requested(
77
+ label: str,
78
+ available: list[str],
79
+ requested: tuple[str, ...] | None,
80
+ diagnostics: list[Diagnostic],
81
+ ) -> set[str] | None:
82
+ if not requested:
83
+ return None
84
+ available_set = set(available)
85
+ for name in requested:
86
+ if name not in available_set:
87
+ diagnostics.append(
88
+ error("EG018", f"unknown name in input.{label}: {name}", path=f"input.{label}")
89
+ )
90
+ return set(requested)
91
+
92
+
93
+ def _referenced(
94
+ entities: Iterable[Entity],
95
+ model: SourceModel,
96
+ selected_types: set[str],
97
+ ) -> set[str]:
98
+ names: set[str] = set()
99
+ for entity in entities:
100
+ for field in entity.fields:
101
+ names.add(field.type)
102
+ type_map = {item.name: item for item in model.types}
103
+ for name in tuple(selected_types):
104
+ custom = type_map.get(name)
105
+ if custom is not None:
106
+ names.add(custom.base)
107
+ return names
108
+
109
+
110
+ __all__ = ["select_slice"]