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,741 @@
|
|
|
1
|
+
"""Artifact planning and writing with a global manifest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import unicodedata
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Generator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
21
|
+
|
|
22
|
+
from eggpipe.core.model import FormatInfo
|
|
23
|
+
from eggpipe.interfaces.diagnostics import Diagnostic, error, warning
|
|
24
|
+
from eggpipe.interfaces.generator import Artifact
|
|
25
|
+
from eggpipe.utils.exceptions import (
|
|
26
|
+
ArtifactPathError,
|
|
27
|
+
LockError,
|
|
28
|
+
ManifestError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
MANIFEST_VERSION = 2
|
|
32
|
+
WINDOWS_RESERVED_NAMES = {
|
|
33
|
+
*(f"COM{i}" for i in range(1, 10)),
|
|
34
|
+
*(f"LPT{i}" for i in range(1, 10)),
|
|
35
|
+
"CON",
|
|
36
|
+
"PRN",
|
|
37
|
+
"AUX",
|
|
38
|
+
"NUL",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, order=True)
|
|
43
|
+
class LocationKey:
|
|
44
|
+
generator: str
|
|
45
|
+
output_dir: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ManifestFormat(BaseModel):
|
|
49
|
+
name: str
|
|
50
|
+
version: str
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ManifestArtifact(BaseModel):
|
|
56
|
+
path: str
|
|
57
|
+
sha256: str
|
|
58
|
+
skipped: bool = False
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ManifestLocation(BaseModel):
|
|
64
|
+
generator: str
|
|
65
|
+
output_dir: str
|
|
66
|
+
eggpipe_version: str
|
|
67
|
+
plugin_version: str
|
|
68
|
+
formats: list[ManifestFormat] = Field(default_factory=list)
|
|
69
|
+
generated_at: datetime
|
|
70
|
+
artifacts: list[ManifestArtifact] = Field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def key(self) -> LocationKey:
|
|
76
|
+
return LocationKey(self.generator, self.output_dir)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Manifest(BaseModel):
|
|
80
|
+
manifest_version: int
|
|
81
|
+
locations: list[ManifestLocation] = Field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class PlannedArtifact:
|
|
88
|
+
generator_id: str
|
|
89
|
+
path: PurePosixPath
|
|
90
|
+
target: Path
|
|
91
|
+
action: Literal["create", "update", "skip"]
|
|
92
|
+
sha256: str
|
|
93
|
+
content: str
|
|
94
|
+
skipped: bool = False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ExecutionPlan:
|
|
99
|
+
artifacts: list[PlannedArtifact] = field(default_factory=list)
|
|
100
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
101
|
+
formats: frozenset[FormatInfo] = frozenset()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def has_errors(self) -> bool:
|
|
105
|
+
return any(item.level == "error" for item in self.diagnostics)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class CleanResult:
|
|
110
|
+
removed_artifacts: int
|
|
111
|
+
removed_locations: int
|
|
112
|
+
diagnostics: list[Diagnostic]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class _PlanState:
|
|
117
|
+
plan: ExecutionPlan
|
|
118
|
+
generator_id: str
|
|
119
|
+
output_resolved: Path
|
|
120
|
+
ownership: dict[Path, LocationKey]
|
|
121
|
+
current: LocationKey
|
|
122
|
+
on_conflict: Literal["overwrite", "skip", "error"]
|
|
123
|
+
force: bool
|
|
124
|
+
seen: dict[str, str] = field(default_factory=dict)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ArtifactWriter:
|
|
128
|
+
def __init__(self, *, eggpipe_version: str, config_dir: Path) -> None:
|
|
129
|
+
self.eggpipe_version = eggpipe_version
|
|
130
|
+
self.config_dir = config_dir.resolve()
|
|
131
|
+
self.state_dir = self.config_dir / ".eggpipe"
|
|
132
|
+
self.manifest_path = self.state_dir / "manifest.json"
|
|
133
|
+
|
|
134
|
+
def plan(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
generator_id: str,
|
|
138
|
+
output_dir: Path,
|
|
139
|
+
artifacts: list[Artifact],
|
|
140
|
+
on_conflict: Literal["overwrite", "skip", "error"] = "overwrite",
|
|
141
|
+
force: bool = False,
|
|
142
|
+
formats: frozenset[FormatInfo] = frozenset(),
|
|
143
|
+
) -> ExecutionPlan:
|
|
144
|
+
manifest = self.load_manifest()
|
|
145
|
+
state = _PlanState(
|
|
146
|
+
plan=ExecutionPlan(formats=formats),
|
|
147
|
+
generator_id=generator_id,
|
|
148
|
+
output_resolved=output_dir.resolve(),
|
|
149
|
+
ownership=self._ownership(manifest),
|
|
150
|
+
current=self.location_key(generator_id, output_dir),
|
|
151
|
+
on_conflict=on_conflict,
|
|
152
|
+
force=force,
|
|
153
|
+
)
|
|
154
|
+
for artifact in artifacts:
|
|
155
|
+
planned = self._plan_artifact(state, artifact)
|
|
156
|
+
if planned is not None:
|
|
157
|
+
state.plan.artifacts.append(planned)
|
|
158
|
+
return state.plan
|
|
159
|
+
|
|
160
|
+
def _plan_artifact(
|
|
161
|
+
self,
|
|
162
|
+
state: _PlanState,
|
|
163
|
+
artifact: Artifact,
|
|
164
|
+
) -> PlannedArtifact | None:
|
|
165
|
+
try:
|
|
166
|
+
target = validate_artifact_path(state.output_resolved, artifact)
|
|
167
|
+
except ArtifactPathError as exc:
|
|
168
|
+
state.plan.diagnostics.append(error("EG040", str(exc), path=artifact.path))
|
|
169
|
+
return None
|
|
170
|
+
normalized = target.relative_to(state.output_resolved).as_posix()
|
|
171
|
+
collision = _collision(state.seen, normalized)
|
|
172
|
+
if collision is not None:
|
|
173
|
+
state.plan.diagnostics.append(
|
|
174
|
+
error(
|
|
175
|
+
"EG042",
|
|
176
|
+
f"duplicate artifact path: {collision} and {normalized}",
|
|
177
|
+
path=normalized,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return None
|
|
181
|
+
state.seen[normalized.casefold()] = normalized
|
|
182
|
+
if target.is_dir():
|
|
183
|
+
state.plan.diagnostics.append(
|
|
184
|
+
error("EG041", "artifact path points to a directory", path=normalized)
|
|
185
|
+
)
|
|
186
|
+
return None
|
|
187
|
+
owner = state.ownership.get(target)
|
|
188
|
+
if owner is not None and owner != state.current:
|
|
189
|
+
state.plan.diagnostics.append(
|
|
190
|
+
error(
|
|
191
|
+
"EG041",
|
|
192
|
+
f"file belongs to another location: {owner.generator}:{owner.output_dir}",
|
|
193
|
+
path=normalized,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return None
|
|
197
|
+
prefix = _owned_prefix(target, state.ownership, state.current)
|
|
198
|
+
if prefix is not None:
|
|
199
|
+
state.plan.diagnostics.append(
|
|
200
|
+
error("EG042", f"path conflict with another location: {prefix}", path=normalized)
|
|
201
|
+
)
|
|
202
|
+
return None
|
|
203
|
+
digest = hashlib.sha256(artifact.content.encode("utf-8")).hexdigest()
|
|
204
|
+
if not target.exists():
|
|
205
|
+
return self._planned(
|
|
206
|
+
generator_id=state.generator_id,
|
|
207
|
+
normalized=normalized,
|
|
208
|
+
target=target,
|
|
209
|
+
action="create",
|
|
210
|
+
digest=digest,
|
|
211
|
+
content=artifact.content,
|
|
212
|
+
)
|
|
213
|
+
return self._plan_existing(state, artifact, target, normalized, digest, owner)
|
|
214
|
+
|
|
215
|
+
def _plan_existing(
|
|
216
|
+
self,
|
|
217
|
+
state: _PlanState,
|
|
218
|
+
artifact: Artifact,
|
|
219
|
+
target: Path,
|
|
220
|
+
normalized: str,
|
|
221
|
+
digest: str,
|
|
222
|
+
owner: LocationKey | None,
|
|
223
|
+
) -> PlannedArtifact | None:
|
|
224
|
+
if state.on_conflict == "error":
|
|
225
|
+
state.plan.diagnostics.append(
|
|
226
|
+
error("EG041", "on_conflict: error — the file already exists", path=normalized)
|
|
227
|
+
)
|
|
228
|
+
return None
|
|
229
|
+
if owner == state.current:
|
|
230
|
+
skipped = state.on_conflict == "skip"
|
|
231
|
+
return self._planned(
|
|
232
|
+
generator_id=state.generator_id,
|
|
233
|
+
normalized=normalized,
|
|
234
|
+
target=target,
|
|
235
|
+
action="skip" if skipped else "update",
|
|
236
|
+
digest=digest,
|
|
237
|
+
content=artifact.content,
|
|
238
|
+
skipped=skipped,
|
|
239
|
+
)
|
|
240
|
+
if state.on_conflict == "skip":
|
|
241
|
+
state.plan.diagnostics.append(
|
|
242
|
+
warning(
|
|
243
|
+
"EG043",
|
|
244
|
+
"non-owned file was not overwritten and not added to the manifest",
|
|
245
|
+
path=normalized,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
return None
|
|
249
|
+
if not state.force:
|
|
250
|
+
state.plan.diagnostics.append(
|
|
251
|
+
error(
|
|
252
|
+
"EG041",
|
|
253
|
+
"the file is not owned by the generator; overwrite only with --force",
|
|
254
|
+
path=normalized,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
return None
|
|
258
|
+
return self._planned(
|
|
259
|
+
generator_id=state.generator_id,
|
|
260
|
+
normalized=normalized,
|
|
261
|
+
target=target,
|
|
262
|
+
action="update",
|
|
263
|
+
digest=digest,
|
|
264
|
+
content=artifact.content,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _planned(
|
|
269
|
+
*,
|
|
270
|
+
generator_id: str,
|
|
271
|
+
normalized: str,
|
|
272
|
+
target: Path,
|
|
273
|
+
action: Literal["create", "update", "skip"],
|
|
274
|
+
digest: str,
|
|
275
|
+
content: str,
|
|
276
|
+
skipped: bool = False,
|
|
277
|
+
) -> PlannedArtifact:
|
|
278
|
+
return PlannedArtifact(
|
|
279
|
+
generator_id=generator_id,
|
|
280
|
+
path=PurePosixPath(normalized),
|
|
281
|
+
target=target,
|
|
282
|
+
action=action,
|
|
283
|
+
sha256=digest,
|
|
284
|
+
content=content,
|
|
285
|
+
skipped=skipped,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def write(
|
|
289
|
+
self,
|
|
290
|
+
*,
|
|
291
|
+
generator_id: str,
|
|
292
|
+
output_dir: Path,
|
|
293
|
+
plan: ExecutionPlan,
|
|
294
|
+
plugin_version: str,
|
|
295
|
+
cleanup: Literal["off", "prompt", "enabled"] = "off",
|
|
296
|
+
) -> list[Diagnostic]:
|
|
297
|
+
if plan.has_errors:
|
|
298
|
+
raise ManifestError("cannot write a plan that has errors")
|
|
299
|
+
diagnostics: list[Diagnostic] = []
|
|
300
|
+
if plan.artifacts:
|
|
301
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
_sweep_staging(output_dir)
|
|
303
|
+
_stage_artifacts(plan, output_dir)
|
|
304
|
+
|
|
305
|
+
entries = self._current_entries(plan)
|
|
306
|
+
current_paths = {str(item.path) for item in plan.artifacts}
|
|
307
|
+
manifest = self.load_manifest()
|
|
308
|
+
old = self._find_location(manifest, generator_id, output_dir)
|
|
309
|
+
if old is not None:
|
|
310
|
+
entries.extend(
|
|
311
|
+
self._cleanup_location(
|
|
312
|
+
old,
|
|
313
|
+
output_dir,
|
|
314
|
+
current_paths,
|
|
315
|
+
cleanup,
|
|
316
|
+
diagnostics,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
location = ManifestLocation(
|
|
320
|
+
generator=generator_id,
|
|
321
|
+
output_dir=self._output_rel(output_dir),
|
|
322
|
+
eggpipe_version=self.eggpipe_version,
|
|
323
|
+
plugin_version=plugin_version,
|
|
324
|
+
formats=_manifest_formats(plan.formats),
|
|
325
|
+
generated_at=datetime.now(UTC),
|
|
326
|
+
artifacts=entries,
|
|
327
|
+
)
|
|
328
|
+
key = self.location_key(generator_id, output_dir)
|
|
329
|
+
locations = [self._prune_missing(item) for item in manifest.locations if item.key != key]
|
|
330
|
+
if entries:
|
|
331
|
+
locations.append(location)
|
|
332
|
+
self.save_manifest(Manifest(manifest_version=MANIFEST_VERSION, locations=locations))
|
|
333
|
+
return diagnostics
|
|
334
|
+
|
|
335
|
+
def pending_deletions(
|
|
336
|
+
self,
|
|
337
|
+
*,
|
|
338
|
+
generator_id: str,
|
|
339
|
+
output_dir: Path,
|
|
340
|
+
plan: ExecutionPlan,
|
|
341
|
+
cleanup: Literal["off", "prompt", "enabled"],
|
|
342
|
+
) -> list[PurePosixPath]:
|
|
343
|
+
if cleanup != "enabled":
|
|
344
|
+
return []
|
|
345
|
+
manifest = self.load_manifest()
|
|
346
|
+
location = self._find_location(manifest, generator_id, output_dir)
|
|
347
|
+
if location is None:
|
|
348
|
+
return []
|
|
349
|
+
current = {str(item.path) for item in plan.artifacts}
|
|
350
|
+
result: list[PurePosixPath] = []
|
|
351
|
+
for artifact in location.artifacts:
|
|
352
|
+
if artifact.path in current or artifact.skipped:
|
|
353
|
+
continue
|
|
354
|
+
target = output_dir / artifact.path
|
|
355
|
+
if not target.exists() or _sha256_file(target) != artifact.sha256:
|
|
356
|
+
continue
|
|
357
|
+
result.append(PurePosixPath(artifact.path))
|
|
358
|
+
return result
|
|
359
|
+
|
|
360
|
+
def check(self, *, plan: ExecutionPlan) -> list[Diagnostic]:
|
|
361
|
+
diagnostics: list[Diagnostic] = []
|
|
362
|
+
for item in plan.artifacts:
|
|
363
|
+
if item.action == "skip":
|
|
364
|
+
continue
|
|
365
|
+
if not item.target.exists():
|
|
366
|
+
diagnostics.append(
|
|
367
|
+
error("EG300", "expected artifact is missing", path=str(item.path))
|
|
368
|
+
)
|
|
369
|
+
continue
|
|
370
|
+
if _sha256_file(item.target) != item.sha256:
|
|
371
|
+
diagnostics.append(error("EG300", "artifact is stale (drift)", path=str(item.path)))
|
|
372
|
+
return diagnostics
|
|
373
|
+
|
|
374
|
+
def clean(self, *, dry_run: bool = False) -> CleanResult:
|
|
375
|
+
manifest = self.load_manifest()
|
|
376
|
+
diagnostics: list[Diagnostic] = []
|
|
377
|
+
removed_artifacts = 0
|
|
378
|
+
kept_locations: list[ManifestLocation] = []
|
|
379
|
+
for location in manifest.locations:
|
|
380
|
+
output_dir = self._location_output(location)
|
|
381
|
+
kept = self._cleanup_location(
|
|
382
|
+
location,
|
|
383
|
+
output_dir,
|
|
384
|
+
current_paths=set(),
|
|
385
|
+
cleanup="enabled",
|
|
386
|
+
diagnostics=diagnostics,
|
|
387
|
+
dry_run=dry_run,
|
|
388
|
+
)
|
|
389
|
+
removed_artifacts += len(location.artifacts) - len(kept)
|
|
390
|
+
if kept:
|
|
391
|
+
kept_locations.append(
|
|
392
|
+
location.model_copy(
|
|
393
|
+
update={
|
|
394
|
+
"generated_at": datetime.now(UTC),
|
|
395
|
+
"artifacts": kept,
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
removed_locations = len(manifest.locations) - len(kept_locations)
|
|
400
|
+
if not dry_run:
|
|
401
|
+
if kept_locations:
|
|
402
|
+
self.save_manifest(
|
|
403
|
+
Manifest(manifest_version=MANIFEST_VERSION, locations=kept_locations)
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
self.manifest_path.unlink(missing_ok=True)
|
|
407
|
+
return CleanResult(
|
|
408
|
+
removed_artifacts=removed_artifacts,
|
|
409
|
+
removed_locations=removed_locations,
|
|
410
|
+
diagnostics=diagnostics,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def load_manifest(self) -> Manifest:
|
|
414
|
+
if not self.manifest_path.is_file():
|
|
415
|
+
return Manifest(manifest_version=MANIFEST_VERSION)
|
|
416
|
+
try:
|
|
417
|
+
data = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
|
418
|
+
manifest = Manifest.model_validate(data)
|
|
419
|
+
except (OSError, json.JSONDecodeError, ValidationError) as exc:
|
|
420
|
+
raise ManifestError(f"failed to read manifest {self.manifest_path}: {exc}") from exc
|
|
421
|
+
if manifest.manifest_version != MANIFEST_VERSION:
|
|
422
|
+
raise ManifestError(f"manifest_version {manifest.manifest_version} is not supported")
|
|
423
|
+
return manifest
|
|
424
|
+
|
|
425
|
+
def save_manifest(self, manifest: Manifest) -> None:
|
|
426
|
+
_write_json_atomic(self.manifest_path, manifest)
|
|
427
|
+
|
|
428
|
+
def location_key(self, generator_id: str, output_dir: Path) -> LocationKey:
|
|
429
|
+
return LocationKey(generator_id, self._output_rel(output_dir))
|
|
430
|
+
|
|
431
|
+
@contextmanager
|
|
432
|
+
def write_lock(self) -> Generator[None, None, None]:
|
|
433
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
434
|
+
lock = self.state_dir / "lock"
|
|
435
|
+
descriptor = _acquire_lock(lock)
|
|
436
|
+
try:
|
|
437
|
+
os.write(descriptor, f"{os.getpid()}\n".encode())
|
|
438
|
+
yield
|
|
439
|
+
finally:
|
|
440
|
+
os.close(descriptor)
|
|
441
|
+
lock.unlink(missing_ok=True)
|
|
442
|
+
|
|
443
|
+
def _current_entries(self, plan: ExecutionPlan) -> list[ManifestArtifact]:
|
|
444
|
+
entries: list[ManifestArtifact] = []
|
|
445
|
+
for item in plan.artifacts:
|
|
446
|
+
if item.action == "skip":
|
|
447
|
+
digest = _sha256_file(item.target) if item.target.exists() else item.sha256
|
|
448
|
+
entries.append(ManifestArtifact(path=str(item.path), sha256=digest, skipped=True))
|
|
449
|
+
else:
|
|
450
|
+
entries.append(
|
|
451
|
+
ManifestArtifact(path=str(item.path), sha256=item.sha256, skipped=False)
|
|
452
|
+
)
|
|
453
|
+
return entries
|
|
454
|
+
|
|
455
|
+
def _cleanup_location(
|
|
456
|
+
self,
|
|
457
|
+
location: ManifestLocation,
|
|
458
|
+
output_dir: Path,
|
|
459
|
+
current_paths: set[str],
|
|
460
|
+
cleanup: Literal["off", "prompt", "enabled"],
|
|
461
|
+
diagnostics: list[Diagnostic],
|
|
462
|
+
*,
|
|
463
|
+
dry_run: bool = False,
|
|
464
|
+
) -> list[ManifestArtifact]:
|
|
465
|
+
kept: list[ManifestArtifact] = []
|
|
466
|
+
for item in location.artifacts:
|
|
467
|
+
if item.path in current_paths:
|
|
468
|
+
continue
|
|
469
|
+
if item.skipped:
|
|
470
|
+
kept.append(item)
|
|
471
|
+
continue
|
|
472
|
+
target = output_dir / item.path
|
|
473
|
+
if not target.exists():
|
|
474
|
+
continue
|
|
475
|
+
if _sha256_file(target) != item.sha256:
|
|
476
|
+
diagnostics.append(
|
|
477
|
+
warning(
|
|
478
|
+
"EG050",
|
|
479
|
+
"modified owned file was not removed during cleanup",
|
|
480
|
+
path=item.path,
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
kept.append(item)
|
|
484
|
+
continue
|
|
485
|
+
if cleanup == "enabled":
|
|
486
|
+
if not dry_run:
|
|
487
|
+
target.unlink()
|
|
488
|
+
else:
|
|
489
|
+
kept.append(item)
|
|
490
|
+
return kept
|
|
491
|
+
|
|
492
|
+
def _find_location(
|
|
493
|
+
self,
|
|
494
|
+
manifest: Manifest,
|
|
495
|
+
generator_id: str,
|
|
496
|
+
output_dir: Path,
|
|
497
|
+
) -> ManifestLocation | None:
|
|
498
|
+
key = self.location_key(generator_id, output_dir)
|
|
499
|
+
for location in manifest.locations:
|
|
500
|
+
if location.key == key:
|
|
501
|
+
return location
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
def _prune_missing(self, location: ManifestLocation) -> ManifestLocation:
|
|
505
|
+
output_dir = self._location_output(location)
|
|
506
|
+
kept = [
|
|
507
|
+
item for item in location.artifacts if item.skipped or (output_dir / item.path).exists()
|
|
508
|
+
]
|
|
509
|
+
if len(kept) == len(location.artifacts):
|
|
510
|
+
return location
|
|
511
|
+
return location.model_copy(update={"artifacts": kept})
|
|
512
|
+
|
|
513
|
+
def _ownership(self, manifest: Manifest) -> dict[Path, LocationKey]:
|
|
514
|
+
result: dict[Path, LocationKey] = {}
|
|
515
|
+
for location in manifest.locations:
|
|
516
|
+
output_dir = self._location_output(location)
|
|
517
|
+
for artifact in location.artifacts:
|
|
518
|
+
target = (output_dir / artifact.path).resolve()
|
|
519
|
+
if target.exists():
|
|
520
|
+
result[target] = location.key
|
|
521
|
+
return result
|
|
522
|
+
|
|
523
|
+
def _location_output(self, location: ManifestLocation) -> Path:
|
|
524
|
+
path = Path(location.output_dir)
|
|
525
|
+
return path if path.is_absolute() else (self.config_dir / path).resolve()
|
|
526
|
+
|
|
527
|
+
def _output_rel(self, output_dir: Path) -> str:
|
|
528
|
+
resolved = output_dir.resolve()
|
|
529
|
+
try:
|
|
530
|
+
return resolved.relative_to(self.config_dir).as_posix()
|
|
531
|
+
except ValueError:
|
|
532
|
+
return resolved.as_posix()
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _sweep_staging(output_dir: Path) -> None:
|
|
536
|
+
for path in output_dir.glob(".eggpipe-staging-*"):
|
|
537
|
+
if path.is_dir():
|
|
538
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _acquire_lock(lock: Path) -> int:
|
|
542
|
+
try:
|
|
543
|
+
return os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
544
|
+
except FileExistsError as exc:
|
|
545
|
+
if not _stale_lock(lock):
|
|
546
|
+
pid = _lock_pid(lock)
|
|
547
|
+
hint = f" (PID {pid})" if pid is not None else ""
|
|
548
|
+
raise LockError(
|
|
549
|
+
f"manifest is already in use: {lock}{hint}; "
|
|
550
|
+
"if no process is running, remove the lock manually"
|
|
551
|
+
) from exc
|
|
552
|
+
lock.unlink(missing_ok=True)
|
|
553
|
+
try:
|
|
554
|
+
return os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
555
|
+
except FileExistsError as retry:
|
|
556
|
+
raise LockError(f"manifest is already in use: {lock}") from retry
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _stale_lock(lock: Path) -> bool:
|
|
560
|
+
pid = _lock_pid(lock)
|
|
561
|
+
return pid is None or not _pid_alive(pid)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _lock_pid(lock: Path) -> int | None:
|
|
565
|
+
try:
|
|
566
|
+
return int(lock.read_text(encoding="utf-8").strip())
|
|
567
|
+
except (OSError, ValueError):
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _pid_alive(pid: int) -> bool:
|
|
572
|
+
if pid <= 0:
|
|
573
|
+
return False
|
|
574
|
+
if sys.platform == "win32":
|
|
575
|
+
return _windows_pid_alive(pid)
|
|
576
|
+
try:
|
|
577
|
+
os.kill(pid, 0)
|
|
578
|
+
return True
|
|
579
|
+
except ProcessLookupError:
|
|
580
|
+
return False
|
|
581
|
+
except PermissionError:
|
|
582
|
+
return True
|
|
583
|
+
except OSError:
|
|
584
|
+
return True
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _windows_pid_alive(pid: int) -> bool:
|
|
588
|
+
import ctypes
|
|
589
|
+
from ctypes import wintypes
|
|
590
|
+
|
|
591
|
+
process_query_limited_information = 0x1000
|
|
592
|
+
still_active = 259
|
|
593
|
+
kernel32 = getattr(ctypes, "WinDLL")("kernel32", use_last_error=True) # noqa: B009
|
|
594
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
595
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
596
|
+
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
597
|
+
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
|
598
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
599
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
600
|
+
|
|
601
|
+
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
|
|
602
|
+
if not handle:
|
|
603
|
+
return False
|
|
604
|
+
try:
|
|
605
|
+
exit_code = wintypes.DWORD()
|
|
606
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
607
|
+
return True
|
|
608
|
+
return exit_code.value == still_active
|
|
609
|
+
finally:
|
|
610
|
+
kernel32.CloseHandle(handle)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _stage_artifacts(plan: ExecutionPlan, output_dir: Path) -> None:
|
|
614
|
+
staging = output_dir / f".eggpipe-staging-{os.getpid()}-{uuid.uuid4().hex}"
|
|
615
|
+
staging.mkdir()
|
|
616
|
+
try:
|
|
617
|
+
for item in plan.artifacts:
|
|
618
|
+
if item.action == "skip":
|
|
619
|
+
continue
|
|
620
|
+
source = staging / item.path
|
|
621
|
+
source.parent.mkdir(parents=True, exist_ok=True)
|
|
622
|
+
source.write_text(item.content, encoding="utf-8", newline="\n")
|
|
623
|
+
item.target.parent.mkdir(parents=True, exist_ok=True)
|
|
624
|
+
os.replace(source, item.target)
|
|
625
|
+
finally:
|
|
626
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _collision(seen: dict[str, str], normalized: str) -> str | None:
|
|
630
|
+
key = normalized.casefold()
|
|
631
|
+
if key in seen:
|
|
632
|
+
return seen[key]
|
|
633
|
+
for other in seen:
|
|
634
|
+
if key.startswith(f"{other}/") or other.startswith(f"{key}/"):
|
|
635
|
+
return seen[other]
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _owned_prefix(
|
|
640
|
+
target: Path,
|
|
641
|
+
ownership: dict[Path, LocationKey],
|
|
642
|
+
current: LocationKey,
|
|
643
|
+
) -> str | None:
|
|
644
|
+
name = target.as_posix().casefold()
|
|
645
|
+
for other, owner in ownership.items():
|
|
646
|
+
if owner == current:
|
|
647
|
+
continue
|
|
648
|
+
other_name = other.as_posix().casefold()
|
|
649
|
+
if name.startswith(f"{other_name}/") or other_name.startswith(f"{name}/"):
|
|
650
|
+
return f"{owner.generator}:{owner.output_dir}"
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _manifest_formats(formats: frozenset[FormatInfo]) -> list[ManifestFormat]:
|
|
655
|
+
return [
|
|
656
|
+
ManifestFormat(name=item.name, version=item.version)
|
|
657
|
+
for item in sorted(formats, key=lambda item: (item.name, item.version))
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def validate_artifact_path(output_dir: Path, artifact: Artifact) -> Path:
|
|
662
|
+
if not isinstance(artifact.content, str):
|
|
663
|
+
raise ArtifactPathError("content must be a string")
|
|
664
|
+
if artifact.content.startswith("\ufeff"):
|
|
665
|
+
raise ArtifactPathError("content must not contain a BOM")
|
|
666
|
+
if "\r" in artifact.content:
|
|
667
|
+
raise ArtifactPathError("content must use LF line endings")
|
|
668
|
+
|
|
669
|
+
raw = unicodedata.normalize("NFC", artifact.path)
|
|
670
|
+
path = PurePosixPath(raw)
|
|
671
|
+
if path.is_absolute() or not path.parts:
|
|
672
|
+
raise ArtifactPathError("path must be relative")
|
|
673
|
+
if len(raw) > 4096:
|
|
674
|
+
raise ArtifactPathError("path is too long")
|
|
675
|
+
for part in path.parts:
|
|
676
|
+
stem = part.split(".", 1)[0].upper()
|
|
677
|
+
if (
|
|
678
|
+
part in {"", ".", ".."}
|
|
679
|
+
or re.match(r"^[A-Za-z]:", part)
|
|
680
|
+
or part.startswith(".eggpipe")
|
|
681
|
+
or stem in WINDOWS_RESERVED_NAMES
|
|
682
|
+
or part.endswith((" ", "."))
|
|
683
|
+
or "\x00" in part
|
|
684
|
+
or "\\" in part
|
|
685
|
+
):
|
|
686
|
+
raise ArtifactPathError(f"invalid path segment: {part!r}")
|
|
687
|
+
if len(part) > 255:
|
|
688
|
+
raise ArtifactPathError("path segment is too long")
|
|
689
|
+
|
|
690
|
+
output_resolved = output_dir.resolve()
|
|
691
|
+
target = (output_resolved / path).resolve()
|
|
692
|
+
if not target.is_relative_to(output_resolved):
|
|
693
|
+
raise ArtifactPathError("path escapes output_dir")
|
|
694
|
+
return target
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _sha256_file(path: Path) -> str:
|
|
698
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _write_json_atomic(path: Path, value: BaseModel) -> None:
|
|
702
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
703
|
+
temporary = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
704
|
+
try:
|
|
705
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
|
|
706
|
+
handle.write(
|
|
707
|
+
json.dumps(value.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n"
|
|
708
|
+
)
|
|
709
|
+
handle.flush()
|
|
710
|
+
os.fsync(handle.fileno())
|
|
711
|
+
os.replace(temporary, path)
|
|
712
|
+
_fsync_directory(path.parent)
|
|
713
|
+
finally:
|
|
714
|
+
temporary.unlink(missing_ok=True)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _fsync_directory(path: Path) -> None:
|
|
718
|
+
try:
|
|
719
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
720
|
+
except OSError:
|
|
721
|
+
return
|
|
722
|
+
try:
|
|
723
|
+
os.fsync(descriptor)
|
|
724
|
+
except OSError:
|
|
725
|
+
pass
|
|
726
|
+
finally:
|
|
727
|
+
os.close(descriptor)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
__all__ = [
|
|
731
|
+
"ArtifactWriter",
|
|
732
|
+
"CleanResult",
|
|
733
|
+
"ExecutionPlan",
|
|
734
|
+
"LocationKey",
|
|
735
|
+
"Manifest",
|
|
736
|
+
"ManifestArtifact",
|
|
737
|
+
"ManifestFormat",
|
|
738
|
+
"ManifestLocation",
|
|
739
|
+
"PlannedArtifact",
|
|
740
|
+
"validate_artifact_path",
|
|
741
|
+
]
|