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