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.
- pytaskwirecheck/__init__.py +32 -0
- pytaskwirecheck/api.py +314 -0
- pytaskwirecheck/cache/__init__.py +0 -0
- pytaskwirecheck/cache/fingerprint.py +176 -0
- pytaskwirecheck/cache/policy.py +49 -0
- pytaskwirecheck/cache/store.py +367 -0
- pytaskwirecheck/cli.py +690 -0
- pytaskwirecheck/config/__init__.py +0 -0
- pytaskwirecheck/config/loader.py +72 -0
- pytaskwirecheck/config/schema.py +170 -0
- pytaskwirecheck/config/validation.py +590 -0
- pytaskwirecheck/diagnostics/__init__.py +31 -0
- pytaskwirecheck/diagnostics/errors.py +86 -0
- pytaskwirecheck/engine/__init__.py +0 -0
- pytaskwirecheck/engine/cancellation.py +38 -0
- pytaskwirecheck/engine/events.py +93 -0
- pytaskwirecheck/engine/executor.py +301 -0
- pytaskwirecheck/engine/scheduler.py +312 -0
- pytaskwirecheck/engine/state.py +123 -0
- pytaskwirecheck/graph/__init__.py +0 -0
- pytaskwirecheck/graph/builder.py +33 -0
- pytaskwirecheck/graph/model.py +104 -0
- pytaskwirecheck/graph/planner.py +155 -0
- pytaskwirecheck/graph/validator.py +92 -0
- pytaskwirecheck/history/__init__.py +0 -0
- pytaskwirecheck/history/store.py +164 -0
- pytaskwirecheck/logging/__init__.py +0 -0
- pytaskwirecheck/logging/artifacts.py +95 -0
- pytaskwirecheck/logging/structured.py +83 -0
- pytaskwirecheck/notifications/__init__.py +0 -0
- pytaskwirecheck/notifications/base.py +42 -0
- pytaskwirecheck/notifications/webhook.py +71 -0
- pytaskwirecheck/processes/__init__.py +0 -0
- pytaskwirecheck/processes/runner.py +265 -0
- pytaskwirecheck/processes/signals.py +65 -0
- pytaskwirecheck/py.typed +0 -0
- pytaskwirecheck/tui/__init__.py +0 -0
- pytaskwirecheck/tui/app.py +204 -0
- pytaskwirecheck/tui/renderer.py +143 -0
- pytaskwirecheck-0.1.0.dist-info/METADATA +316 -0
- pytaskwirecheck-0.1.0.dist-info/RECORD +45 -0
- pytaskwirecheck-0.1.0.dist-info/WHEEL +5 -0
- pytaskwirecheck-0.1.0.dist-info/entry_points.txt +2 -0
- pytaskwirecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|