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,566 @@
|
|
|
1
|
+
"""Discovery of model files, model units and resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from eggpipe.core.model import InputSelection
|
|
12
|
+
from eggpipe.core.paths import is_ignored_part
|
|
13
|
+
from eggpipe.interfaces.diagnostics import Diagnostic, error, warning
|
|
14
|
+
from eggpipe.interfaces.generator import ImportedFile
|
|
15
|
+
from eggpipe.utils.yaml import YamlLoadError, load_yaml
|
|
16
|
+
|
|
17
|
+
GLOB_CHARS = re.compile(r"[*?\[]")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResourceLike(Protocol):
|
|
21
|
+
"""Resource entry consumed by discovery."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def path(self) -> Path: ...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def alias(self) -> str | None: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ModelOverrideLike(Protocol):
|
|
31
|
+
"""Per-model file and resource override."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def files(self) -> tuple[str, ...] | None: ...
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def resources(self) -> tuple[ResourceLike, ...] | None: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SourceLike(Protocol):
|
|
41
|
+
"""Source entry consumed by discovery."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def path(self) -> Path: ...
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def layout(self) -> str: ...
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def files(self) -> tuple[str, ...] | None: ...
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def model_dirs(self) -> tuple[str, ...] | None: ...
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def models(self) -> Mapping[str, ModelOverrideLike]: ...
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def input(self) -> InputSelection: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GeneratorLike(Protocol):
|
|
63
|
+
"""Generator entry consumed by discovery."""
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def output_dir(self) -> Path: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProjectConfigLike(Protocol):
|
|
70
|
+
"""Project configuration consumed by discovery."""
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def resources(self) -> tuple[ResourceLike, ...]: ...
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def sources(self) -> tuple[SourceLike, ...]: ...
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def generators(self) -> tuple[GeneratorLike, ...]: ...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class ModelUnit:
|
|
84
|
+
paths: tuple[Path, ...]
|
|
85
|
+
layered: bool = False
|
|
86
|
+
model_dir: Path | None = None
|
|
87
|
+
single_entity: bool = False
|
|
88
|
+
stale_dates: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class SourceGroup:
|
|
93
|
+
paths: tuple[Path, ...]
|
|
94
|
+
input: InputSelection
|
|
95
|
+
units: tuple[ModelUnit, ...] | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class DiscoveredSources:
|
|
100
|
+
paths: list[Path] = field(default_factory=list)
|
|
101
|
+
groups: list[SourceGroup] = field(default_factory=list)
|
|
102
|
+
resources: dict[str, ImportedFile] = field(default_factory=dict)
|
|
103
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def discover_sources(
|
|
107
|
+
rules: ProjectConfigLike,
|
|
108
|
+
config_dir: Path,
|
|
109
|
+
*,
|
|
110
|
+
allow_outside: bool = False,
|
|
111
|
+
) -> DiscoveredSources:
|
|
112
|
+
result = DiscoveredSources()
|
|
113
|
+
config_root = config_dir.resolve()
|
|
114
|
+
output_dirs = {(config_root / item.output_dir).resolve() for item in rules.generators}
|
|
115
|
+
_collect_resources(
|
|
116
|
+
rules.resources,
|
|
117
|
+
base=config_root,
|
|
118
|
+
boundary=config_root,
|
|
119
|
+
prefix=None,
|
|
120
|
+
allow_outside=allow_outside,
|
|
121
|
+
result=result,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
seen: set[Path] = set()
|
|
125
|
+
for source in rules.sources:
|
|
126
|
+
root = (config_root / source.path).resolve()
|
|
127
|
+
if not root.exists():
|
|
128
|
+
result.diagnostics.append(
|
|
129
|
+
error("EG000", f"source not found: {source.path}", path=str(root))
|
|
130
|
+
)
|
|
131
|
+
continue
|
|
132
|
+
if not root.is_relative_to(config_root):
|
|
133
|
+
result.diagnostics.append(
|
|
134
|
+
error("EG022", "source is outside the project root", path=str(root))
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
if source.layout == "monolith":
|
|
138
|
+
_discover_monolith(root, source, seen, result)
|
|
139
|
+
elif source.layout == "file-per-model":
|
|
140
|
+
_discover_file_per_model(root, source, output_dirs, seen, config_root, result)
|
|
141
|
+
else:
|
|
142
|
+
_discover_directory_per_model(
|
|
143
|
+
root,
|
|
144
|
+
source,
|
|
145
|
+
output_dirs,
|
|
146
|
+
seen,
|
|
147
|
+
allow_outside,
|
|
148
|
+
config_root,
|
|
149
|
+
result,
|
|
150
|
+
)
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _discover_monolith(
|
|
155
|
+
root: Path,
|
|
156
|
+
source: SourceLike,
|
|
157
|
+
seen: set[Path],
|
|
158
|
+
result: DiscoveredSources,
|
|
159
|
+
) -> None:
|
|
160
|
+
if not root.is_file():
|
|
161
|
+
result.diagnostics.append(
|
|
162
|
+
error("EG000", f"monolith source must be a file: {source.path}", path=str(root))
|
|
163
|
+
)
|
|
164
|
+
return
|
|
165
|
+
canonical = root.resolve()
|
|
166
|
+
if canonical in seen:
|
|
167
|
+
result.diagnostics.append(
|
|
168
|
+
warning("EG024", "duplicate resolved source path", path=str(canonical))
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
seen.add(canonical)
|
|
172
|
+
_check_resource_overlap([canonical], result)
|
|
173
|
+
result.paths.append(canonical)
|
|
174
|
+
unit = ModelUnit(paths=(canonical,))
|
|
175
|
+
result.groups.append(SourceGroup(paths=(canonical,), input=source.input, units=(unit,)))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _discover_file_per_model(
|
|
179
|
+
root: Path,
|
|
180
|
+
source: SourceLike,
|
|
181
|
+
output_dirs: set[Path],
|
|
182
|
+
seen: set[Path],
|
|
183
|
+
boundary: Path,
|
|
184
|
+
result: DiscoveredSources,
|
|
185
|
+
) -> None:
|
|
186
|
+
if not root.is_dir():
|
|
187
|
+
result.diagnostics.append(
|
|
188
|
+
error(
|
|
189
|
+
"EG000",
|
|
190
|
+
f"file-per-model source must be a directory: {source.path}",
|
|
191
|
+
path=str(root),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
files = _match_files(root, source.files or (), output_dirs, result, boundary)
|
|
196
|
+
canonical_files: list[Path] = []
|
|
197
|
+
for path in files:
|
|
198
|
+
canonical = path.resolve()
|
|
199
|
+
if canonical in seen:
|
|
200
|
+
result.diagnostics.append(
|
|
201
|
+
warning("EG024", "duplicate resolved source path", path=str(canonical))
|
|
202
|
+
)
|
|
203
|
+
continue
|
|
204
|
+
seen.add(canonical)
|
|
205
|
+
result.paths.append(canonical)
|
|
206
|
+
canonical_files.append(canonical)
|
|
207
|
+
if canonical_files:
|
|
208
|
+
_check_resource_overlap(canonical_files, result)
|
|
209
|
+
units = tuple(
|
|
210
|
+
ModelUnit(paths=(path,), single_entity=True, stale_dates=True)
|
|
211
|
+
for path in canonical_files
|
|
212
|
+
)
|
|
213
|
+
result.groups.append(
|
|
214
|
+
SourceGroup(paths=tuple(canonical_files), input=source.input, units=units)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _discover_directory_per_model(
|
|
219
|
+
root: Path,
|
|
220
|
+
source: SourceLike,
|
|
221
|
+
output_dirs: set[Path],
|
|
222
|
+
seen: set[Path],
|
|
223
|
+
allow_outside: bool,
|
|
224
|
+
config_root: Path,
|
|
225
|
+
result: DiscoveredSources,
|
|
226
|
+
) -> None:
|
|
227
|
+
if not root.is_dir():
|
|
228
|
+
result.diagnostics.append(
|
|
229
|
+
error(
|
|
230
|
+
"EG000",
|
|
231
|
+
f"directory-per-model source must be a directory: {source.path}",
|
|
232
|
+
path=str(root),
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
return
|
|
236
|
+
model_dirs = _match_dirs(root, source.model_dirs or (), output_dirs, result, config_root)
|
|
237
|
+
known = {path.name for path in model_dirs}
|
|
238
|
+
unknown = sorted(set(source.models) - known)
|
|
239
|
+
for name in unknown:
|
|
240
|
+
result.diagnostics.append(
|
|
241
|
+
error("EG037", f"models.{name} refers to an unknown directory", path=str(root))
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
units: list[ModelUnit] = []
|
|
245
|
+
group_paths: list[Path] = []
|
|
246
|
+
for model_dir in model_dirs:
|
|
247
|
+
override = source.models.get(model_dir.name)
|
|
248
|
+
patterns = override.files if override is not None and override.files is not None else None
|
|
249
|
+
files = _match_files(
|
|
250
|
+
model_dir,
|
|
251
|
+
patterns if patterns is not None else (source.files or ()),
|
|
252
|
+
output_dirs,
|
|
253
|
+
result,
|
|
254
|
+
config_root,
|
|
255
|
+
)
|
|
256
|
+
if not files:
|
|
257
|
+
result.diagnostics.append(
|
|
258
|
+
warning("EG027", f"empty model directory: {model_dir.name}", path=str(model_dir))
|
|
259
|
+
)
|
|
260
|
+
continue
|
|
261
|
+
resources: list[Path] = []
|
|
262
|
+
if override is not None and override.resources is not None:
|
|
263
|
+
_collect_resources(
|
|
264
|
+
override.resources,
|
|
265
|
+
base=model_dir,
|
|
266
|
+
boundary=model_dir,
|
|
267
|
+
prefix=model_dir.name,
|
|
268
|
+
allow_outside=allow_outside,
|
|
269
|
+
result=result,
|
|
270
|
+
collected=resources,
|
|
271
|
+
)
|
|
272
|
+
_check_resource_overlap(
|
|
273
|
+
files,
|
|
274
|
+
result,
|
|
275
|
+
extra={path.resolve() for path in resources},
|
|
276
|
+
)
|
|
277
|
+
unique: list[Path] = []
|
|
278
|
+
for path in files:
|
|
279
|
+
canonical = path.resolve()
|
|
280
|
+
if canonical in seen:
|
|
281
|
+
result.diagnostics.append(
|
|
282
|
+
warning("EG024", "duplicate resolved source path", path=str(canonical))
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
seen.add(canonical)
|
|
286
|
+
unique.append(canonical)
|
|
287
|
+
if not unique:
|
|
288
|
+
continue
|
|
289
|
+
result.paths.extend(unique)
|
|
290
|
+
group_paths.extend(unique)
|
|
291
|
+
units.append(ModelUnit(paths=tuple(unique), layered=True, model_dir=model_dir))
|
|
292
|
+
if units:
|
|
293
|
+
result.groups.append(
|
|
294
|
+
SourceGroup(paths=tuple(group_paths), input=source.input, units=tuple(units))
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _match_files(
|
|
299
|
+
root: Path,
|
|
300
|
+
patterns: tuple[str, ...],
|
|
301
|
+
output_dirs: set[Path],
|
|
302
|
+
result: DiscoveredSources,
|
|
303
|
+
boundary: Path,
|
|
304
|
+
) -> list[Path]:
|
|
305
|
+
found: dict[Path, None] = {}
|
|
306
|
+
for pattern in patterns:
|
|
307
|
+
matches = _expand(root, pattern, result, expect="file")
|
|
308
|
+
for path in matches:
|
|
309
|
+
if _is_in_output(path, output_dirs):
|
|
310
|
+
continue
|
|
311
|
+
resolved = path.resolve()
|
|
312
|
+
if not resolved.is_relative_to(boundary):
|
|
313
|
+
result.diagnostics.append(
|
|
314
|
+
error("EG022", "model file is outside the project root", path=str(resolved))
|
|
315
|
+
)
|
|
316
|
+
continue
|
|
317
|
+
if resolved.suffix.lower() in {".yaml", ".yml"} and not _is_model_document(resolved):
|
|
318
|
+
result.diagnostics.append(
|
|
319
|
+
error("EG023", "YAML is not a model document", path=str(resolved))
|
|
320
|
+
)
|
|
321
|
+
continue
|
|
322
|
+
found[resolved] = None
|
|
323
|
+
return [Path(path) for path in sorted(found, key=lambda item: item.as_posix())]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _match_dirs(
|
|
327
|
+
root: Path,
|
|
328
|
+
patterns: tuple[str, ...],
|
|
329
|
+
output_dirs: set[Path],
|
|
330
|
+
result: DiscoveredSources,
|
|
331
|
+
boundary: Path,
|
|
332
|
+
) -> list[Path]:
|
|
333
|
+
found: dict[Path, None] = {}
|
|
334
|
+
for pattern in patterns:
|
|
335
|
+
matches = _expand(root, pattern, result, expect="dir")
|
|
336
|
+
for path in matches:
|
|
337
|
+
if _is_in_output(path, output_dirs):
|
|
338
|
+
continue
|
|
339
|
+
resolved = path.resolve()
|
|
340
|
+
if not resolved.is_relative_to(boundary):
|
|
341
|
+
result.diagnostics.append(
|
|
342
|
+
error(
|
|
343
|
+
"EG022", "model directory is outside the project root", path=str(resolved)
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
continue
|
|
347
|
+
found[resolved] = None
|
|
348
|
+
return [Path(path) for path in sorted(found, key=lambda item: item.as_posix())]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _expand(
|
|
352
|
+
root: Path,
|
|
353
|
+
pattern: str,
|
|
354
|
+
result: DiscoveredSources,
|
|
355
|
+
*,
|
|
356
|
+
expect: str,
|
|
357
|
+
) -> list[Path]:
|
|
358
|
+
raw = Path(pattern)
|
|
359
|
+
if raw.is_absolute():
|
|
360
|
+
result.diagnostics.append(
|
|
361
|
+
error("EG000", f"absolute path in sources is not allowed: {pattern}", path=str(root))
|
|
362
|
+
)
|
|
363
|
+
return []
|
|
364
|
+
if GLOB_CHARS.search(pattern):
|
|
365
|
+
matches = [
|
|
366
|
+
path
|
|
367
|
+
for path in root.glob(pattern)
|
|
368
|
+
if (path.is_file() if expect == "file" else path.is_dir())
|
|
369
|
+
]
|
|
370
|
+
matches = [path for path in matches if not _is_hidden(root, path)]
|
|
371
|
+
if not matches:
|
|
372
|
+
result.diagnostics.append(
|
|
373
|
+
warning("EG036", f"glob matched nothing: {pattern}", path=str(root))
|
|
374
|
+
)
|
|
375
|
+
return sorted(matches, key=lambda item: item.as_posix())
|
|
376
|
+
candidate = (root / raw).resolve()
|
|
377
|
+
if expect == "file" and not candidate.is_file():
|
|
378
|
+
result.diagnostics.append(error("EG000", f"file not found: {pattern}", path=str(candidate)))
|
|
379
|
+
return []
|
|
380
|
+
if expect == "dir" and not candidate.is_dir():
|
|
381
|
+
result.diagnostics.append(
|
|
382
|
+
error("EG000", f"directory not found: {pattern}", path=str(candidate))
|
|
383
|
+
)
|
|
384
|
+
return []
|
|
385
|
+
if _is_hidden(root, candidate):
|
|
386
|
+
return []
|
|
387
|
+
return [candidate]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@dataclass
|
|
391
|
+
class _ResourceCollector:
|
|
392
|
+
base: Path
|
|
393
|
+
boundary: Path
|
|
394
|
+
prefix: str | None
|
|
395
|
+
allow_outside: bool
|
|
396
|
+
result: DiscoveredSources
|
|
397
|
+
collected: list[Path] | None = None
|
|
398
|
+
|
|
399
|
+
def collect(self, resources: tuple[ResourceLike, ...]) -> None:
|
|
400
|
+
for resource in resources:
|
|
401
|
+
self._collect_one(resource)
|
|
402
|
+
|
|
403
|
+
def _collect_one(self, resource: ResourceLike) -> None:
|
|
404
|
+
raw = resource.path
|
|
405
|
+
if raw.is_absolute():
|
|
406
|
+
self.result.diagnostics.append(
|
|
407
|
+
error("EG000", f"absolute resource path is not allowed: {raw}", path=str(self.base))
|
|
408
|
+
)
|
|
409
|
+
return
|
|
410
|
+
pattern = raw.as_posix()
|
|
411
|
+
if GLOB_CHARS.search(pattern):
|
|
412
|
+
self._collect_glob(resource, pattern)
|
|
413
|
+
return
|
|
414
|
+
candidate = (self.base / raw).resolve()
|
|
415
|
+
if candidate.is_dir():
|
|
416
|
+
self._collect_directory(resource, pattern, candidate)
|
|
417
|
+
return
|
|
418
|
+
if not candidate.is_file():
|
|
419
|
+
self.result.diagnostics.append(
|
|
420
|
+
error("EG000", f"resource not found: {pattern}", path=str(candidate))
|
|
421
|
+
)
|
|
422
|
+
return
|
|
423
|
+
self._add(candidate, alias=resource.alias)
|
|
424
|
+
|
|
425
|
+
def _collect_glob(self, resource: ResourceLike, pattern: str) -> None:
|
|
426
|
+
if resource.alias is not None:
|
|
427
|
+
self.result.diagnostics.append(
|
|
428
|
+
error("EG032", "as is only allowed for a single file", path=pattern)
|
|
429
|
+
)
|
|
430
|
+
return
|
|
431
|
+
matches = [
|
|
432
|
+
path
|
|
433
|
+
for path in self.base.glob(pattern)
|
|
434
|
+
if path.is_file() and not _is_hidden(self.base, path)
|
|
435
|
+
]
|
|
436
|
+
if not matches:
|
|
437
|
+
self.result.diagnostics.append(
|
|
438
|
+
warning(
|
|
439
|
+
"EG036",
|
|
440
|
+
f"resource glob matched nothing: {pattern}",
|
|
441
|
+
path=str(self.base),
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
for path in sorted(matches, key=lambda item: item.as_posix()):
|
|
445
|
+
self._add(path, alias=None)
|
|
446
|
+
|
|
447
|
+
def _collect_directory(
|
|
448
|
+
self,
|
|
449
|
+
resource: ResourceLike,
|
|
450
|
+
pattern: str,
|
|
451
|
+
directory: Path,
|
|
452
|
+
) -> None:
|
|
453
|
+
if resource.alias is not None:
|
|
454
|
+
self.result.diagnostics.append(
|
|
455
|
+
error("EG032", "as is only allowed for a single file", path=pattern)
|
|
456
|
+
)
|
|
457
|
+
return
|
|
458
|
+
files = sorted(
|
|
459
|
+
(
|
|
460
|
+
path
|
|
461
|
+
for path in directory.rglob("*")
|
|
462
|
+
if path.is_file() and not _is_hidden(directory, path)
|
|
463
|
+
),
|
|
464
|
+
key=lambda item: item.as_posix(),
|
|
465
|
+
)
|
|
466
|
+
for path in files:
|
|
467
|
+
self._add(path, alias=None)
|
|
468
|
+
|
|
469
|
+
def _add(self, path: Path, *, alias: str | None) -> None:
|
|
470
|
+
resolved = path.resolve()
|
|
471
|
+
if not self.allow_outside and not resolved.is_relative_to(self.boundary.resolve()):
|
|
472
|
+
self.result.diagnostics.append(
|
|
473
|
+
error("EG022", "resource is outside the allowed boundary", path=str(resolved))
|
|
474
|
+
)
|
|
475
|
+
return
|
|
476
|
+
if self.collected is not None:
|
|
477
|
+
self.collected.append(resolved)
|
|
478
|
+
name = alias or _default_alias(self.base, resolved, self.prefix)
|
|
479
|
+
if name in self.result.resources:
|
|
480
|
+
self.result.diagnostics.append(
|
|
481
|
+
error("EG032", f"duplicate resource alias: {name}", path=str(resolved))
|
|
482
|
+
)
|
|
483
|
+
return
|
|
484
|
+
try:
|
|
485
|
+
content = resolved.read_bytes()
|
|
486
|
+
except OSError as exc:
|
|
487
|
+
self.result.diagnostics.append(
|
|
488
|
+
error("EG000", f"failed to read resource: {exc}", path=str(resolved))
|
|
489
|
+
)
|
|
490
|
+
return
|
|
491
|
+
self.result.resources[name] = ImportedFile(name=name, path=resolved, content=content)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _collect_resources(
|
|
495
|
+
resources: tuple[ResourceLike, ...],
|
|
496
|
+
*,
|
|
497
|
+
base: Path,
|
|
498
|
+
boundary: Path,
|
|
499
|
+
prefix: str | None,
|
|
500
|
+
allow_outside: bool,
|
|
501
|
+
result: DiscoveredSources,
|
|
502
|
+
collected: list[Path] | None = None,
|
|
503
|
+
) -> None:
|
|
504
|
+
_ResourceCollector(
|
|
505
|
+
base=base,
|
|
506
|
+
boundary=boundary,
|
|
507
|
+
prefix=prefix,
|
|
508
|
+
allow_outside=allow_outside,
|
|
509
|
+
result=result,
|
|
510
|
+
collected=collected,
|
|
511
|
+
).collect(resources)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _check_resource_overlap(
|
|
515
|
+
files: list[Path],
|
|
516
|
+
result: DiscoveredSources,
|
|
517
|
+
*,
|
|
518
|
+
extra: set[Path] | None = None,
|
|
519
|
+
) -> None:
|
|
520
|
+
resources = {item.path.resolve() for item in result.resources.values()}
|
|
521
|
+
if extra:
|
|
522
|
+
resources |= extra
|
|
523
|
+
overlap = {path.resolve() for path in files} & resources
|
|
524
|
+
for path in sorted(overlap, key=lambda item: item.as_posix()):
|
|
525
|
+
result.diagnostics.append(
|
|
526
|
+
error(
|
|
527
|
+
"EG038",
|
|
528
|
+
"path is listed in both files and resources",
|
|
529
|
+
path=str(path),
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _is_model_document(path: Path) -> bool:
|
|
535
|
+
try:
|
|
536
|
+
data = load_yaml(path)
|
|
537
|
+
except YamlLoadError:
|
|
538
|
+
# Syntax and duplicate keys are reported by Parser with EG000.
|
|
539
|
+
return True
|
|
540
|
+
return bool(data.get("format"))
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _default_alias(base: Path, path: Path, prefix: str | None) -> str:
|
|
544
|
+
try:
|
|
545
|
+
relative = path.relative_to(base.resolve()).with_suffix("").as_posix()
|
|
546
|
+
except ValueError:
|
|
547
|
+
relative = path.with_suffix("").as_posix()
|
|
548
|
+
return f"{prefix}/{relative}" if prefix else relative
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _is_hidden(root: Path, path: Path) -> bool:
|
|
552
|
+
try:
|
|
553
|
+
relative = path.resolve().relative_to(root.resolve())
|
|
554
|
+
except ValueError:
|
|
555
|
+
relative = path
|
|
556
|
+
return any(is_ignored_part(part) for part in relative.parts)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _is_in_output(path: Path, output_dirs: set[Path]) -> bool:
|
|
560
|
+
resolved = path.resolve()
|
|
561
|
+
return any(
|
|
562
|
+
resolved == directory or resolved.is_relative_to(directory) for directory in output_dirs
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
__all__ = ["DiscoveredSources", "ModelUnit", "SourceGroup", "discover_sources"]
|
eggpipe/core/document.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Model document and imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from eggpipe.core.model import SourceModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImportSpec(BaseModel):
|
|
14
|
+
path: str
|
|
15
|
+
alias: str | None = None
|
|
16
|
+
kind: Literal["yaml", "resource"]
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModelDocument(BaseModel):
|
|
22
|
+
format: str
|
|
23
|
+
version: str
|
|
24
|
+
path: Path
|
|
25
|
+
imports: tuple[ImportSpec, ...] = ()
|
|
26
|
+
model: SourceModel
|
|
27
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["ImportSpec", "ModelDocument"]
|