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