PyTaskWireCheck 0.1.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.
Files changed (45) hide show
  1. pytaskwirecheck/__init__.py +32 -0
  2. pytaskwirecheck/api.py +314 -0
  3. pytaskwirecheck/cache/__init__.py +0 -0
  4. pytaskwirecheck/cache/fingerprint.py +176 -0
  5. pytaskwirecheck/cache/policy.py +49 -0
  6. pytaskwirecheck/cache/store.py +367 -0
  7. pytaskwirecheck/cli.py +690 -0
  8. pytaskwirecheck/config/__init__.py +0 -0
  9. pytaskwirecheck/config/loader.py +72 -0
  10. pytaskwirecheck/config/schema.py +170 -0
  11. pytaskwirecheck/config/validation.py +590 -0
  12. pytaskwirecheck/diagnostics/__init__.py +31 -0
  13. pytaskwirecheck/diagnostics/errors.py +86 -0
  14. pytaskwirecheck/engine/__init__.py +0 -0
  15. pytaskwirecheck/engine/cancellation.py +38 -0
  16. pytaskwirecheck/engine/events.py +93 -0
  17. pytaskwirecheck/engine/executor.py +301 -0
  18. pytaskwirecheck/engine/scheduler.py +312 -0
  19. pytaskwirecheck/engine/state.py +123 -0
  20. pytaskwirecheck/graph/__init__.py +0 -0
  21. pytaskwirecheck/graph/builder.py +33 -0
  22. pytaskwirecheck/graph/model.py +104 -0
  23. pytaskwirecheck/graph/planner.py +155 -0
  24. pytaskwirecheck/graph/validator.py +92 -0
  25. pytaskwirecheck/history/__init__.py +0 -0
  26. pytaskwirecheck/history/store.py +164 -0
  27. pytaskwirecheck/logging/__init__.py +0 -0
  28. pytaskwirecheck/logging/artifacts.py +95 -0
  29. pytaskwirecheck/logging/structured.py +83 -0
  30. pytaskwirecheck/notifications/__init__.py +0 -0
  31. pytaskwirecheck/notifications/base.py +42 -0
  32. pytaskwirecheck/notifications/webhook.py +71 -0
  33. pytaskwirecheck/processes/__init__.py +0 -0
  34. pytaskwirecheck/processes/runner.py +265 -0
  35. pytaskwirecheck/processes/signals.py +65 -0
  36. pytaskwirecheck/py.typed +0 -0
  37. pytaskwirecheck/tui/__init__.py +0 -0
  38. pytaskwirecheck/tui/app.py +204 -0
  39. pytaskwirecheck/tui/renderer.py +143 -0
  40. pytaskwirecheck-0.1.0.dist-info/METADATA +316 -0
  41. pytaskwirecheck-0.1.0.dist-info/RECORD +45 -0
  42. pytaskwirecheck-0.1.0.dist-info/WHEEL +5 -0
  43. pytaskwirecheck-0.1.0.dist-info/entry_points.txt +2 -0
  44. pytaskwirecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
  45. pytaskwirecheck-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,367 @@
1
+ """Local content-addressed cache storage.
2
+
3
+ Layout (under ``<root>/.pytaskwirecheck/cache``)::
4
+
5
+ entries/<key>/meta.json # fingerprint + artifact manifest
6
+ entries/<key>/artifacts/... # stored copies of declared outputs
7
+ stage/ # staging area for atomic commits
8
+ locks/ # short-lived commit locks
9
+
10
+ Correctness rules:
11
+
12
+ * Only successful executions commit entries.
13
+ * Commits are atomic: artifacts are staged, then the directory is renamed.
14
+ * Restores verify artifact integrity first; a corrupt or partial entry is a
15
+ miss, never a false success.
16
+ * Restoration is confined to the workspace root.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import shutil
24
+ import time
25
+ import uuid
26
+ from dataclasses import asdict
27
+ from pathlib import Path
28
+ from typing import Dict, List, Mapping, Optional, Sequence, Tuple
29
+
30
+ from pytaskwirecheck import __version__
31
+ from pytaskwirecheck.cache.fingerprint import (
32
+ CACHE_SCHEMA_VERSION,
33
+ DEFAULT_RELEVANT_ENV,
34
+ Fingerprint,
35
+ compute_fingerprint,
36
+ hash_file,
37
+ )
38
+ from pytaskwirecheck.config.loader import state_dir
39
+ from pytaskwirecheck.config.schema import TaskConfig
40
+ from pytaskwirecheck.diagnostics.errors import CacheError
41
+
42
+ _LOCK_TIMEOUT = 10.0
43
+ _LOCK_POLL = 0.05
44
+
45
+
46
+ class _CommitLock:
47
+ """A best-effort cross-process lock for committing a single cache key."""
48
+
49
+ def __init__(self, path: Path) -> None:
50
+ self._path = path
51
+ self._fd: Optional[int] = None
52
+
53
+ def __enter__(self) -> "_CommitLock":
54
+ deadline = time.monotonic() + _LOCK_TIMEOUT
55
+ self._path.parent.mkdir(parents=True, exist_ok=True)
56
+ while True:
57
+ try:
58
+ self._fd = os.open(
59
+ str(self._path), os.O_CREAT | os.O_EXCL | os.O_WRONLY
60
+ )
61
+ return self
62
+ except FileExistsError:
63
+ if time.monotonic() >= deadline:
64
+ # Stale lock; break it and proceed.
65
+ try:
66
+ os.unlink(self._path)
67
+ except OSError:
68
+ pass
69
+ continue
70
+ time.sleep(_LOCK_POLL)
71
+
72
+ def __exit__(self, *exc: object) -> None:
73
+ if self._fd is not None:
74
+ try:
75
+ os.close(self._fd)
76
+ except OSError:
77
+ pass
78
+ self._fd = None
79
+ try:
80
+ os.unlink(self._path)
81
+ except OSError:
82
+ pass
83
+
84
+
85
+ class CacheStore:
86
+ """A local, self-contained content-addressed cache."""
87
+
88
+ def __init__(
89
+ self,
90
+ root: Path,
91
+ *,
92
+ base_env: Optional[Mapping[str, str]] = None,
93
+ relevant_env: Sequence[str] = DEFAULT_RELEVANT_ENV,
94
+ ) -> None:
95
+ self._root = root.resolve()
96
+ self._base = state_dir(self._root) / "cache"
97
+ self._entries = self._base / "entries"
98
+ self._stage = self._base / "stage"
99
+ self._locks = self._base / "locks"
100
+ self._base_env = base_env
101
+ self._relevant_env = tuple(relevant_env)
102
+
103
+ @property
104
+ def location(self) -> Path:
105
+ return self._base
106
+
107
+ def fingerprint(
108
+ self, task: TaskConfig, dependency_keys: Mapping[str, Optional[str]]
109
+ ) -> Fingerprint:
110
+ return compute_fingerprint(
111
+ task,
112
+ self._root,
113
+ dependency_keys,
114
+ base_env=self._base_env,
115
+ relevant_env=self._relevant_env,
116
+ )
117
+
118
+ # -- lookup / restore ---------------------------------------------------
119
+
120
+ def try_restore(
121
+ self, task: TaskConfig, dependency_keys: Mapping[str, Optional[str]]
122
+ ) -> Optional[str]:
123
+ if not task.cache_eligible:
124
+ return None
125
+ fp = self.fingerprint(task, dependency_keys)
126
+ entry = self._entries / fp.key
127
+ meta_path = entry / "meta.json"
128
+ if not meta_path.is_file():
129
+ return None
130
+ try:
131
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
132
+ except (OSError, ValueError):
133
+ self._discard(fp.key)
134
+ return None
135
+ if meta.get("schema") != CACHE_SCHEMA_VERSION:
136
+ self._discard(fp.key)
137
+ return None
138
+
139
+ artifacts = meta.get("artifacts", [])
140
+ if not self._verify_artifacts(entry, artifacts):
141
+ self._discard(fp.key)
142
+ return None
143
+ if not self._restore_artifacts(entry, artifacts):
144
+ return None
145
+ return fp.key
146
+
147
+ def _verify_artifacts(self, entry: Path, artifacts: List[dict]) -> bool:
148
+ for artifact in artifacts:
149
+ stored = entry / "artifacts" / artifact["path"]
150
+ if not stored.is_file():
151
+ return False
152
+ try:
153
+ if stored.stat().st_size != artifact.get("size"):
154
+ return False
155
+ if hash_file(stored) != artifact.get("sha256"):
156
+ return False
157
+ except OSError:
158
+ return False
159
+ return True
160
+
161
+ def _safe_destination(self, rel: str) -> Optional[Path]:
162
+ if os.path.isabs(rel):
163
+ return None
164
+ destination = (self._root / rel).resolve()
165
+ try:
166
+ destination.relative_to(self._root)
167
+ except ValueError:
168
+ return None
169
+ return destination
170
+
171
+ def _restore_artifacts(self, entry: Path, artifacts: List[dict]) -> bool:
172
+ for artifact in artifacts:
173
+ destination = self._safe_destination(artifact["path"])
174
+ if destination is None:
175
+ return False
176
+ source = entry / "artifacts" / artifact["path"]
177
+ try:
178
+ destination.parent.mkdir(parents=True, exist_ok=True)
179
+ shutil.copy2(source, destination)
180
+ except OSError:
181
+ return False
182
+ return True
183
+
184
+ # -- commit / store -----------------------------------------------------
185
+
186
+ def store(
187
+ self, task: TaskConfig, dependency_keys: Mapping[str, Optional[str]]
188
+ ) -> Optional[str]:
189
+ if not task.cache_eligible:
190
+ return None
191
+ collected, missing = self._collect_outputs(task)
192
+ if not task.outputs:
193
+ return None
194
+ if missing:
195
+ # Declared output absent: refuse to cache an incomplete result.
196
+ return None
197
+
198
+ fp = self.fingerprint(task, dependency_keys)
199
+ stage_dir = self._stage / ("%s.%s" % (fp.key, uuid.uuid4().hex))
200
+ artifacts_dir = stage_dir / "artifacts"
201
+ artifacts: List[dict] = []
202
+ try:
203
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
204
+ for rel, source in collected:
205
+ dest = artifacts_dir / rel
206
+ dest.parent.mkdir(parents=True, exist_ok=True)
207
+ shutil.copy2(source, dest)
208
+ artifacts.append(
209
+ {
210
+ "path": rel,
211
+ "sha256": hash_file(dest),
212
+ "size": dest.stat().st_size,
213
+ }
214
+ )
215
+ meta = {
216
+ "schema": CACHE_SCHEMA_VERSION,
217
+ "key": fp.key,
218
+ "task_id": task.id,
219
+ "created_at": time.time(),
220
+ "pytaskwirecheck_version": __version__,
221
+ "fingerprint": fp.payload,
222
+ "artifacts": artifacts,
223
+ }
224
+ (stage_dir / "meta.json").write_text(
225
+ json.dumps(meta, indent=2, sort_keys=True), encoding="utf-8"
226
+ )
227
+ self._commit(fp.key, stage_dir)
228
+ except OSError as exc:
229
+ shutil.rmtree(stage_dir, ignore_errors=True)
230
+ raise CacheError("failed to store cache entry: %s" % exc) from exc
231
+ return fp.key
232
+
233
+ def _collect_outputs(
234
+ self, task: TaskConfig
235
+ ) -> Tuple[List[Tuple[str, Path]], List[str]]:
236
+ collected: Dict[str, Path] = {}
237
+ missing: List[str] = []
238
+ for pattern in task.outputs:
239
+ matches = sorted(self._root.glob(pattern))
240
+ matched_any = False
241
+ for match in matches:
242
+ if match.is_dir():
243
+ files = [f for f in _iter_files(match)]
244
+ if files:
245
+ matched_any = True
246
+ else:
247
+ files = [match]
248
+ matched_any = True
249
+ for file_path in files:
250
+ try:
251
+ rel = file_path.resolve().relative_to(self._root).as_posix()
252
+ except ValueError:
253
+ continue
254
+ collected[rel] = file_path
255
+ if not matched_any:
256
+ missing.append(pattern)
257
+ return (
258
+ [(rel, collected[rel]) for rel in sorted(collected)],
259
+ missing,
260
+ )
261
+
262
+ def _commit(self, key: str, stage_dir: Path) -> None:
263
+ target = self._entries / key
264
+ lock = _CommitLock(self._locks / ("%s.lock" % key))
265
+ with lock:
266
+ self._entries.mkdir(parents=True, exist_ok=True)
267
+ if target.exists():
268
+ shutil.rmtree(target, ignore_errors=True)
269
+ try:
270
+ os.replace(str(stage_dir), str(target))
271
+ except OSError:
272
+ shutil.rmtree(stage_dir, ignore_errors=True)
273
+ raise
274
+
275
+ def _discard(self, key: str) -> None:
276
+ shutil.rmtree(self._entries / key, ignore_errors=True)
277
+
278
+ # -- inspection ---------------------------------------------------------
279
+
280
+ def explain(
281
+ self, task: TaskConfig, dependency_keys: Mapping[str, Optional[str]]
282
+ ) -> dict:
283
+ """Explain why a task would hit or miss the cache."""
284
+ if not task.cache:
285
+ return {"eligible": False, "reason": "caching disabled for task"}
286
+ if not task.cache_eligible:
287
+ return {
288
+ "eligible": False,
289
+ "reason": "task declares no inputs or outputs",
290
+ }
291
+ fp = self.fingerprint(task, dependency_keys)
292
+ entry = self._entries / fp.key
293
+ if not (entry / "meta.json").is_file():
294
+ return {
295
+ "eligible": True,
296
+ "hit": False,
297
+ "key": fp.key,
298
+ "reason": "no cache entry for computed fingerprint",
299
+ "inputs": [asdict(f) for f in fp.inputs],
300
+ "missing_patterns": list(fp.missing_patterns),
301
+ "env_names": list(fp.env_names),
302
+ }
303
+ meta = json.loads((entry / "meta.json").read_text(encoding="utf-8"))
304
+ valid = self._verify_artifacts(entry, meta.get("artifacts", []))
305
+ return {
306
+ "eligible": True,
307
+ "hit": valid,
308
+ "key": fp.key,
309
+ "reason": "entry valid" if valid else "entry corrupted",
310
+ "inputs": [asdict(f) for f in fp.inputs],
311
+ "artifacts": meta.get("artifacts", []),
312
+ }
313
+
314
+ def status(self) -> dict:
315
+ if not self._entries.is_dir():
316
+ return {
317
+ "location": str(self._base),
318
+ "entries": 0,
319
+ "size_bytes": 0,
320
+ "corrupted": 0,
321
+ }
322
+ entries = 0
323
+ corrupted = 0
324
+ size = 0
325
+ for child in sorted(self._entries.iterdir()):
326
+ if not child.is_dir():
327
+ continue
328
+ entries += 1
329
+ meta_path = child / "meta.json"
330
+ if not meta_path.is_file():
331
+ corrupted += 1
332
+ else:
333
+ try:
334
+ json.loads(meta_path.read_text(encoding="utf-8"))
335
+ except (OSError, ValueError):
336
+ corrupted += 1
337
+ size += _dir_size(child)
338
+ return {
339
+ "location": str(self._base),
340
+ "entries": entries,
341
+ "size_bytes": size,
342
+ "corrupted": corrupted,
343
+ }
344
+
345
+ def clean(self) -> dict:
346
+ freed = _dir_size(self._base) if self._base.is_dir() else 0
347
+ shutil.rmtree(self._base, ignore_errors=True)
348
+ return {"removed_bytes": freed}
349
+
350
+
351
+ def _iter_files(base: Path):
352
+ for dirpath, _dirnames, filenames in os.walk(base):
353
+ for name in sorted(filenames):
354
+ candidate = Path(dirpath) / name
355
+ if candidate.is_file() and not candidate.is_symlink():
356
+ yield candidate
357
+
358
+
359
+ def _dir_size(path: Path) -> int:
360
+ total = 0
361
+ for dirpath, _dirnames, filenames in os.walk(path):
362
+ for name in filenames:
363
+ try:
364
+ total += (Path(dirpath) / name).stat().st_size
365
+ except OSError:
366
+ continue
367
+ return total