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,32 @@
|
|
|
1
|
+
"""PyTaskWireCheck: local-first, dependency-aware task orchestration.
|
|
2
|
+
|
|
3
|
+
PyTaskWireCheck orchestrates work. It does not own the internals of the tools it runs.
|
|
4
|
+
|
|
5
|
+
The public API is intentionally small: load a workflow, inspect its plan, and
|
|
6
|
+
run it. See :mod:`pytaskwirecheck.api` for details.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from pytaskwirecheck.api import ( # noqa: E402
|
|
12
|
+
Workflow,
|
|
13
|
+
load_workflow,
|
|
14
|
+
plan,
|
|
15
|
+
run,
|
|
16
|
+
validate,
|
|
17
|
+
)
|
|
18
|
+
from pytaskwirecheck.engine.scheduler import RunResult # noqa: E402
|
|
19
|
+
from pytaskwirecheck.engine.state import TaskStatus # noqa: E402
|
|
20
|
+
from pytaskwirecheck.graph.planner import ExecutionPlan # noqa: E402
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
"Workflow",
|
|
25
|
+
"ExecutionPlan",
|
|
26
|
+
"RunResult",
|
|
27
|
+
"TaskStatus",
|
|
28
|
+
"load_workflow",
|
|
29
|
+
"validate",
|
|
30
|
+
"plan",
|
|
31
|
+
"run",
|
|
32
|
+
]
|
pytaskwirecheck/api.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""High-level Python API for embedding PyTaskWireCheck.
|
|
2
|
+
|
|
3
|
+
The command-line interface is a thin shell over this module. Library users can
|
|
4
|
+
load a workflow, inspect its plan, and execute it in-process::
|
|
5
|
+
|
|
6
|
+
from pytaskwirecheck import Workflow
|
|
7
|
+
|
|
8
|
+
workflow = Workflow.from_file("pytaskwirecheck.toml")
|
|
9
|
+
result = workflow.run()
|
|
10
|
+
if not result.succeeded:
|
|
11
|
+
raise SystemExit(1)
|
|
12
|
+
|
|
13
|
+
Everything here is local-only. Importing this module never executes tasks,
|
|
14
|
+
touches the network, or reads configuration implicitly; work begins only when
|
|
15
|
+
:meth:`Workflow.run` is called.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import uuid
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Callable, List, Optional, Sequence, Set, Union
|
|
24
|
+
|
|
25
|
+
from pytaskwirecheck.cache.policy import build_store
|
|
26
|
+
from pytaskwirecheck.config.loader import find_config, load_config, parse_toml
|
|
27
|
+
from pytaskwirecheck.config.schema import ExecutionConfig, WorkflowConfig
|
|
28
|
+
from pytaskwirecheck.config.validation import build_workflow
|
|
29
|
+
from pytaskwirecheck.diagnostics.errors import ConfigError, HistoryError
|
|
30
|
+
from pytaskwirecheck.engine.cancellation import CancellationToken
|
|
31
|
+
from pytaskwirecheck.engine.events import EventBus
|
|
32
|
+
from pytaskwirecheck.engine.executor import TaskExecutor
|
|
33
|
+
from pytaskwirecheck.engine.scheduler import RunResult, Scheduler
|
|
34
|
+
from pytaskwirecheck.graph.builder import build_graph
|
|
35
|
+
from pytaskwirecheck.graph.model import TaskGraph
|
|
36
|
+
from pytaskwirecheck.graph.planner import ExecutionPlan, build_plan, resolve_targets
|
|
37
|
+
from pytaskwirecheck.graph.validator import validate_graph
|
|
38
|
+
from pytaskwirecheck.history.store import HistoryStore
|
|
39
|
+
from pytaskwirecheck.notifications.base import RunNotification
|
|
40
|
+
from pytaskwirecheck.notifications.webhook import WebhookNotifier
|
|
41
|
+
|
|
42
|
+
_log = logging.getLogger("pytaskwirecheck")
|
|
43
|
+
|
|
44
|
+
EventCallback = Callable[[Any], None]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Workflow:
|
|
48
|
+
"""A loaded, validated workflow that can be planned and executed."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, config: WorkflowConfig) -> None:
|
|
51
|
+
self._config = config
|
|
52
|
+
self._graph: Optional[TaskGraph] = None
|
|
53
|
+
|
|
54
|
+
# -- construction ------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_file(
|
|
58
|
+
cls,
|
|
59
|
+
path: Optional[Union[str, Path]] = None,
|
|
60
|
+
*,
|
|
61
|
+
root: Optional[Union[str, Path]] = None,
|
|
62
|
+
) -> "Workflow":
|
|
63
|
+
"""Load a workflow from ``path`` or the nearest ``pytaskwirecheck.toml``."""
|
|
64
|
+
if path is None:
|
|
65
|
+
found = find_config(Path.cwd())
|
|
66
|
+
if found is None:
|
|
67
|
+
raise ConfigError(
|
|
68
|
+
"no pytaskwirecheck.toml found in %s or any parent directory"
|
|
69
|
+
% Path.cwd()
|
|
70
|
+
)
|
|
71
|
+
config_path = found
|
|
72
|
+
else:
|
|
73
|
+
config_path = Path(path).resolve()
|
|
74
|
+
root_override = str(root) if root is not None else None
|
|
75
|
+
return cls(load_config(config_path, root_override=root_override))
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_text(
|
|
79
|
+
cls,
|
|
80
|
+
text: str,
|
|
81
|
+
*,
|
|
82
|
+
root: Optional[Union[str, Path]] = None,
|
|
83
|
+
name: str = "pytaskwirecheck.toml",
|
|
84
|
+
) -> "Workflow":
|
|
85
|
+
"""Parse a workflow from an in-memory TOML document."""
|
|
86
|
+
base = Path(root).resolve() if root is not None else Path.cwd()
|
|
87
|
+
raw = parse_toml(text, location=name)
|
|
88
|
+
config = build_workflow(raw, base, base / name)
|
|
89
|
+
return cls(config)
|
|
90
|
+
|
|
91
|
+
# -- inspection --------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def config(self) -> WorkflowConfig:
|
|
95
|
+
"""The validated workflow configuration."""
|
|
96
|
+
return self._config
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def graph(self) -> TaskGraph:
|
|
100
|
+
"""The validated task graph, built once and cached."""
|
|
101
|
+
if self._graph is None:
|
|
102
|
+
graph = build_graph(self._config)
|
|
103
|
+
validate_graph(graph)
|
|
104
|
+
self._graph = graph
|
|
105
|
+
return self._graph
|
|
106
|
+
|
|
107
|
+
def validate(self) -> ExecutionPlan:
|
|
108
|
+
"""Validate the workflow and return the full execution plan."""
|
|
109
|
+
return build_plan(self.graph)
|
|
110
|
+
|
|
111
|
+
def plan(self, targets: Optional[Sequence[str]] = None) -> ExecutionPlan:
|
|
112
|
+
"""Return the execution plan for ``targets`` (or everything)."""
|
|
113
|
+
graph = self.graph
|
|
114
|
+
subset: Optional[Set[str]] = None
|
|
115
|
+
if targets:
|
|
116
|
+
subset = resolve_targets(graph, list(targets))
|
|
117
|
+
return build_plan(graph, subset)
|
|
118
|
+
|
|
119
|
+
# -- execution ---------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def run(
|
|
122
|
+
self,
|
|
123
|
+
targets: Optional[Sequence[str]] = None,
|
|
124
|
+
*,
|
|
125
|
+
use_cache: bool = True,
|
|
126
|
+
max_workers: Optional[int] = None,
|
|
127
|
+
fail_fast: Optional[bool] = None,
|
|
128
|
+
on_event: Optional[EventCallback] = None,
|
|
129
|
+
cancel_token: Optional[CancellationToken] = None,
|
|
130
|
+
record_history: bool = True,
|
|
131
|
+
notify: bool = True,
|
|
132
|
+
) -> RunResult:
|
|
133
|
+
"""Execute the workflow and return the aggregate :class:`RunResult`.
|
|
134
|
+
|
|
135
|
+
``on_event`` receives every engine event, enabling custom progress
|
|
136
|
+
reporting. ``cancel_token`` allows cooperative cancellation from
|
|
137
|
+
another thread. History and notifications are best-effort and never
|
|
138
|
+
alter the returned result.
|
|
139
|
+
"""
|
|
140
|
+
graph = self.graph
|
|
141
|
+
selected: Set[str] = (
|
|
142
|
+
resolve_targets(graph, list(targets)) if targets else set(graph.nodes)
|
|
143
|
+
)
|
|
144
|
+
execution = self._resolve_execution(max_workers, fail_fast)
|
|
145
|
+
run_id = uuid.uuid4().hex[:12]
|
|
146
|
+
|
|
147
|
+
events = EventBus()
|
|
148
|
+
if on_event is not None:
|
|
149
|
+
events.subscribe(on_event)
|
|
150
|
+
token = cancel_token if cancel_token is not None else CancellationToken()
|
|
151
|
+
|
|
152
|
+
cache_enabled = self._config.cache.enabled and use_cache
|
|
153
|
+
store = build_store(self._config) if cache_enabled else None
|
|
154
|
+
|
|
155
|
+
executor = TaskExecutor(
|
|
156
|
+
root=self._config.project.root,
|
|
157
|
+
default_timeout=execution.default_timeout_seconds,
|
|
158
|
+
events=events,
|
|
159
|
+
cancel_token=token,
|
|
160
|
+
cache=store,
|
|
161
|
+
)
|
|
162
|
+
scheduler = Scheduler(
|
|
163
|
+
graph=graph,
|
|
164
|
+
execution=execution,
|
|
165
|
+
executor=executor,
|
|
166
|
+
events=events,
|
|
167
|
+
cancel_token=token,
|
|
168
|
+
use_cache=cache_enabled,
|
|
169
|
+
)
|
|
170
|
+
result = scheduler.run(selected, run_id=run_id)
|
|
171
|
+
|
|
172
|
+
if record_history:
|
|
173
|
+
self._record_history(result)
|
|
174
|
+
if notify:
|
|
175
|
+
self._notify(result)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
# -- helpers -----------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _resolve_execution(
|
|
181
|
+
self, max_workers: Optional[int], fail_fast: Optional[bool]
|
|
182
|
+
) -> ExecutionConfig:
|
|
183
|
+
execution = self._config.execution
|
|
184
|
+
if max_workers is None and fail_fast is None:
|
|
185
|
+
return execution
|
|
186
|
+
if max_workers is not None and max_workers < 1:
|
|
187
|
+
raise ConfigError("max_workers must be >= 1")
|
|
188
|
+
return ExecutionConfig(
|
|
189
|
+
max_workers=(
|
|
190
|
+
max_workers if max_workers is not None else execution.max_workers
|
|
191
|
+
),
|
|
192
|
+
default_timeout_seconds=execution.default_timeout_seconds,
|
|
193
|
+
fail_fast=(fail_fast if fail_fast is not None else execution.fail_fast),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _record_history(self, result: RunResult) -> None:
|
|
197
|
+
if not self._config.history.enabled:
|
|
198
|
+
return
|
|
199
|
+
store = HistoryStore(
|
|
200
|
+
self._config.project.root, max_runs=self._config.history.max_runs
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
store.record_run(
|
|
204
|
+
result,
|
|
205
|
+
project=self._config.project.name,
|
|
206
|
+
config_version=self._config.project.version,
|
|
207
|
+
)
|
|
208
|
+
except HistoryError as exc:
|
|
209
|
+
_log.warning("could not record history: %s", exc)
|
|
210
|
+
finally:
|
|
211
|
+
store.close()
|
|
212
|
+
|
|
213
|
+
def _notify(self, result: RunResult) -> None:
|
|
214
|
+
settings = self._config.notifications
|
|
215
|
+
if not settings.enabled:
|
|
216
|
+
return
|
|
217
|
+
event = "success" if result.succeeded else "failure"
|
|
218
|
+
notifier = WebhookNotifier(
|
|
219
|
+
settings.url,
|
|
220
|
+
timeout_seconds=settings.timeout_seconds,
|
|
221
|
+
max_attempts=settings.max_attempts,
|
|
222
|
+
events=settings.on,
|
|
223
|
+
)
|
|
224
|
+
if not notifier.wants(event):
|
|
225
|
+
return
|
|
226
|
+
notification = RunNotification(
|
|
227
|
+
event=event,
|
|
228
|
+
run_id=result.run_id,
|
|
229
|
+
project=self._config.project.name,
|
|
230
|
+
succeeded=result.succeeded,
|
|
231
|
+
cancelled=result.cancelled,
|
|
232
|
+
duration_seconds=result.duration_seconds,
|
|
233
|
+
counts=result.counts,
|
|
234
|
+
failed_tasks=self._failed_tasks(result),
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
delivered = notifier.send(notification)
|
|
238
|
+
except Exception as exc: # defensive: never break the run
|
|
239
|
+
_log.warning("notification failed: %s", type(exc).__name__)
|
|
240
|
+
return
|
|
241
|
+
if not delivered:
|
|
242
|
+
_log.warning("notification was not delivered")
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _failed_tasks(result: RunResult) -> List[dict]:
|
|
246
|
+
failed = []
|
|
247
|
+
for task_id, record in sorted(result.records.items()):
|
|
248
|
+
if record.status.is_failure:
|
|
249
|
+
failed.append(
|
|
250
|
+
{
|
|
251
|
+
"task": task_id,
|
|
252
|
+
"status": record.status.value,
|
|
253
|
+
"exit_code": record.exit_code,
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
return failed
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Functional conveniences
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def load_workflow(
|
|
265
|
+
path: Optional[Union[str, Path]] = None,
|
|
266
|
+
*,
|
|
267
|
+
root: Optional[Union[str, Path]] = None,
|
|
268
|
+
) -> Workflow:
|
|
269
|
+
"""Load a :class:`Workflow` from ``path`` or the nearest config file."""
|
|
270
|
+
return Workflow.from_file(path, root=root)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def validate(
|
|
274
|
+
path: Optional[Union[str, Path]] = None,
|
|
275
|
+
*,
|
|
276
|
+
root: Optional[Union[str, Path]] = None,
|
|
277
|
+
) -> ExecutionPlan:
|
|
278
|
+
"""Validate a workflow and return its full :class:`ExecutionPlan`."""
|
|
279
|
+
return load_workflow(path, root=root).validate()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def plan(
|
|
283
|
+
targets: Optional[Sequence[str]] = None,
|
|
284
|
+
*,
|
|
285
|
+
path: Optional[Union[str, Path]] = None,
|
|
286
|
+
root: Optional[Union[str, Path]] = None,
|
|
287
|
+
) -> ExecutionPlan:
|
|
288
|
+
"""Return the plan for ``targets`` without executing anything."""
|
|
289
|
+
return load_workflow(path, root=root).plan(targets)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def run(
|
|
293
|
+
targets: Optional[Sequence[str]] = None,
|
|
294
|
+
*,
|
|
295
|
+
path: Optional[Union[str, Path]] = None,
|
|
296
|
+
root: Optional[Union[str, Path]] = None,
|
|
297
|
+
use_cache: bool = True,
|
|
298
|
+
max_workers: Optional[int] = None,
|
|
299
|
+
fail_fast: Optional[bool] = None,
|
|
300
|
+
on_event: Optional[EventCallback] = None,
|
|
301
|
+
record_history: bool = True,
|
|
302
|
+
notify: bool = True,
|
|
303
|
+
) -> RunResult:
|
|
304
|
+
"""Load and execute a workflow in one call."""
|
|
305
|
+
workflow = load_workflow(path, root=root)
|
|
306
|
+
return workflow.run(
|
|
307
|
+
targets,
|
|
308
|
+
use_cache=use_cache,
|
|
309
|
+
max_workers=max_workers,
|
|
310
|
+
fail_fast=fail_fast,
|
|
311
|
+
on_event=on_event,
|
|
312
|
+
record_history=record_history,
|
|
313
|
+
notify=notify,
|
|
314
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Content-addressed cache fingerprinting.
|
|
2
|
+
|
|
3
|
+
The fingerprint captures everything PyTaskWireCheck knows can affect a task's result:
|
|
4
|
+
its command, shell mode, working directory, declared inputs (by content), the
|
|
5
|
+
cache keys of its declared dependencies, relevant environment variables (hashed,
|
|
6
|
+
never stored in plaintext), and a small runtime identity.
|
|
7
|
+
|
|
8
|
+
Anything *not* declared is, by definition, invisible to the cache. Tasks that
|
|
9
|
+
depend on undeclared files, the network, the clock, or other nondeterminism may
|
|
10
|
+
receive incorrect cache hits; see ``docs/caching.md``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import platform
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
23
|
+
|
|
24
|
+
from pytaskwirecheck.config.schema import DEFAULT_RELEVANT_ENV, TaskConfig
|
|
25
|
+
|
|
26
|
+
#: Bump when the fingerprint algorithm or payload shape changes incompatibly.
|
|
27
|
+
CACHE_SCHEMA_VERSION = 1
|
|
28
|
+
|
|
29
|
+
_CHUNK = 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class InputFile:
|
|
34
|
+
path: str
|
|
35
|
+
sha256: str
|
|
36
|
+
size: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Fingerprint:
|
|
41
|
+
"""The cache key plus the information needed to explain a hit or miss."""
|
|
42
|
+
|
|
43
|
+
key: str
|
|
44
|
+
payload: dict
|
|
45
|
+
inputs: Tuple[InputFile, ...]
|
|
46
|
+
missing_patterns: Tuple[str, ...]
|
|
47
|
+
env_names: Tuple[str, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def hash_file(path: Path) -> str:
|
|
51
|
+
digest = hashlib.sha256()
|
|
52
|
+
with open(path, "rb") as handle:
|
|
53
|
+
while True:
|
|
54
|
+
chunk = handle.read(_CHUNK)
|
|
55
|
+
if not chunk:
|
|
56
|
+
break
|
|
57
|
+
digest.update(chunk)
|
|
58
|
+
return digest.hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _safe_iter_files(root: Path, base: Path) -> Iterable[Path]:
|
|
62
|
+
"""Yield regular files under ``base`` (a file or directory)."""
|
|
63
|
+
if base.is_file():
|
|
64
|
+
yield base
|
|
65
|
+
return
|
|
66
|
+
if base.is_dir():
|
|
67
|
+
for dirpath, _dirnames, filenames in os.walk(base):
|
|
68
|
+
for name in sorted(filenames):
|
|
69
|
+
candidate = Path(dirpath) / name
|
|
70
|
+
if candidate.is_file() and not candidate.is_symlink():
|
|
71
|
+
yield candidate
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def expand_inputs(
|
|
75
|
+
root: Path, patterns: Sequence[str]
|
|
76
|
+
) -> Tuple[List[InputFile], List[str]]:
|
|
77
|
+
"""Expand input globs into a sorted list of hashed files.
|
|
78
|
+
|
|
79
|
+
Returns the hashed files and the patterns that matched nothing.
|
|
80
|
+
"""
|
|
81
|
+
root = root.resolve()
|
|
82
|
+
seen: Dict[str, InputFile] = {}
|
|
83
|
+
missing: List[str] = []
|
|
84
|
+
for pattern in patterns:
|
|
85
|
+
matches = sorted(root.glob(pattern))
|
|
86
|
+
matched_any = False
|
|
87
|
+
for match in matches:
|
|
88
|
+
if match.is_dir():
|
|
89
|
+
files = list(_safe_iter_files(root, match))
|
|
90
|
+
if files:
|
|
91
|
+
matched_any = True
|
|
92
|
+
else:
|
|
93
|
+
files = [match]
|
|
94
|
+
matched_any = True
|
|
95
|
+
for file_path in files:
|
|
96
|
+
try:
|
|
97
|
+
rel = file_path.resolve().relative_to(root).as_posix()
|
|
98
|
+
except ValueError:
|
|
99
|
+
continue
|
|
100
|
+
if rel in seen:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
size = file_path.stat().st_size
|
|
104
|
+
digest = hash_file(file_path)
|
|
105
|
+
except OSError:
|
|
106
|
+
continue
|
|
107
|
+
seen[rel] = InputFile(path=rel, sha256=digest, size=size)
|
|
108
|
+
if not matched_any:
|
|
109
|
+
missing.append(pattern)
|
|
110
|
+
return [seen[key] for key in sorted(seen)], missing
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _hash_env_values(values: Mapping[str, str]) -> Dict[str, str]:
|
|
114
|
+
return {
|
|
115
|
+
name: hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
116
|
+
for name, value in sorted(values.items())
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_effective_env(
|
|
121
|
+
task: TaskConfig, base_env: Mapping[str, str]
|
|
122
|
+
) -> Dict[str, str]:
|
|
123
|
+
env = dict(base_env) if task.inherit_env else {}
|
|
124
|
+
env.update(task.env)
|
|
125
|
+
return env
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def compute_fingerprint(
|
|
129
|
+
task: TaskConfig,
|
|
130
|
+
root: Path,
|
|
131
|
+
dependency_keys: Mapping[str, Optional[str]],
|
|
132
|
+
*,
|
|
133
|
+
base_env: Optional[Mapping[str, str]] = None,
|
|
134
|
+
relevant_env: Sequence[str] = DEFAULT_RELEVANT_ENV,
|
|
135
|
+
) -> Fingerprint:
|
|
136
|
+
"""Compute the cache fingerprint for ``task``."""
|
|
137
|
+
base_env = base_env if base_env is not None else os.environ
|
|
138
|
+
effective_env = build_effective_env(task, base_env)
|
|
139
|
+
|
|
140
|
+
relevant_names = sorted(set(relevant_env) | set(task.env.keys()))
|
|
141
|
+
selected_env = {
|
|
142
|
+
name: effective_env[name] for name in relevant_names if name in effective_env
|
|
143
|
+
}
|
|
144
|
+
hashed_env = _hash_env_values(selected_env)
|
|
145
|
+
|
|
146
|
+
inputs, missing = expand_inputs(root, task.inputs)
|
|
147
|
+
|
|
148
|
+
payload = {
|
|
149
|
+
"schema": CACHE_SCHEMA_VERSION,
|
|
150
|
+
"task_id": task.id,
|
|
151
|
+
"command": list(task.command),
|
|
152
|
+
"shell": task.shell,
|
|
153
|
+
"cwd": task.cwd or ".",
|
|
154
|
+
"timeout_seconds": task.timeout_seconds,
|
|
155
|
+
"env": hashed_env,
|
|
156
|
+
"input_patterns": list(task.inputs),
|
|
157
|
+
"inputs": [
|
|
158
|
+
{"path": f.path, "sha256": f.sha256, "size": f.size} for f in inputs
|
|
159
|
+
],
|
|
160
|
+
"outputs": list(task.outputs),
|
|
161
|
+
"dependencies": {name: key for name, key in sorted(dependency_keys.items())},
|
|
162
|
+
"runtime": {
|
|
163
|
+
"python": "%d.%d.%d" % sys.version_info[:3],
|
|
164
|
+
"platform": sys.platform,
|
|
165
|
+
"machine": platform.machine(),
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
169
|
+
key = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
170
|
+
return Fingerprint(
|
|
171
|
+
key=key,
|
|
172
|
+
payload=payload,
|
|
173
|
+
inputs=tuple(inputs),
|
|
174
|
+
missing_patterns=tuple(missing),
|
|
175
|
+
env_names=tuple(sorted(selected_env.keys())),
|
|
176
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Cache policy decisions and construction helpers.
|
|
2
|
+
|
|
3
|
+
Policy is deliberately simple: the global config may disable the cache, the
|
|
4
|
+
CLI may disable it for a single run (``--no-cache``), and individual tasks may
|
|
5
|
+
opt out. A task with no declared inputs or outputs is never cached because its
|
|
6
|
+
side effects are unknown.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Mapping, Optional
|
|
13
|
+
|
|
14
|
+
from pytaskwirecheck.cache.store import CacheStore
|
|
15
|
+
from pytaskwirecheck.config.schema import TaskConfig, WorkflowConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CacheDecision:
|
|
20
|
+
use_cache: bool
|
|
21
|
+
reason: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decide(
|
|
25
|
+
task: TaskConfig, *, global_enabled: bool, cli_disabled: bool
|
|
26
|
+
) -> CacheDecision:
|
|
27
|
+
"""Decide whether cache reads/writes apply to ``task`` in this run."""
|
|
28
|
+
if cli_disabled:
|
|
29
|
+
return CacheDecision(False, "disabled for this run (--no-cache)")
|
|
30
|
+
if not global_enabled:
|
|
31
|
+
return CacheDecision(False, "disabled in configuration")
|
|
32
|
+
if not task.cache:
|
|
33
|
+
return CacheDecision(False, "opted out by task")
|
|
34
|
+
if not task.cache_eligible:
|
|
35
|
+
return CacheDecision(False, "no declared inputs or outputs")
|
|
36
|
+
return CacheDecision(True, "eligible")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_store(
|
|
40
|
+
config: WorkflowConfig,
|
|
41
|
+
*,
|
|
42
|
+
base_env: Optional[Mapping[str, str]] = None,
|
|
43
|
+
) -> CacheStore:
|
|
44
|
+
"""Construct a :class:`CacheStore` for ``config``."""
|
|
45
|
+
return CacheStore(
|
|
46
|
+
config.project.root,
|
|
47
|
+
base_env=base_env,
|
|
48
|
+
relevant_env=config.cache.relevant_env,
|
|
49
|
+
)
|