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,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
+ ]