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
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"]
|
eggpipe/core/slicing.py
ADDED
|
@@ -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"]
|