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
pytaskwirecheck/cli.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""PyTaskWireCheck command-line interface.
|
|
2
|
+
|
|
3
|
+
Commands are intentionally thin: they load/validate configuration, build and
|
|
4
|
+
validate the graph, and delegate execution to the engine. All user-facing
|
|
5
|
+
errors are reported concisely with a stable exit code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional, Sequence, TextIO
|
|
17
|
+
|
|
18
|
+
from pytaskwirecheck import __version__
|
|
19
|
+
from pytaskwirecheck.cache.policy import build_store, decide
|
|
20
|
+
from pytaskwirecheck.config.loader import find_config, load_config
|
|
21
|
+
from pytaskwirecheck.config.schema import WorkflowConfig
|
|
22
|
+
from pytaskwirecheck.diagnostics.errors import (
|
|
23
|
+
ConfigError,
|
|
24
|
+
GraphError,
|
|
25
|
+
HistoryError,
|
|
26
|
+
PyTaskWireCheckError,
|
|
27
|
+
)
|
|
28
|
+
from pytaskwirecheck.engine.cancellation import CancellationToken
|
|
29
|
+
from pytaskwirecheck.engine.events import EventBus
|
|
30
|
+
from pytaskwirecheck.engine.executor import TaskExecutor
|
|
31
|
+
from pytaskwirecheck.engine.scheduler import RunResult, Scheduler
|
|
32
|
+
from pytaskwirecheck.graph.builder import build_graph
|
|
33
|
+
from pytaskwirecheck.graph.model import TaskGraph
|
|
34
|
+
from pytaskwirecheck.graph.planner import build_plan, resolve_targets
|
|
35
|
+
from pytaskwirecheck.graph.validator import validate_graph
|
|
36
|
+
from pytaskwirecheck.history.store import HistoryStore
|
|
37
|
+
from pytaskwirecheck.logging.artifacts import ArtifactWriter
|
|
38
|
+
from pytaskwirecheck.logging.structured import StructuredLogger
|
|
39
|
+
from pytaskwirecheck.notifications.base import RunNotification
|
|
40
|
+
from pytaskwirecheck.notifications.webhook import WebhookNotifier
|
|
41
|
+
from pytaskwirecheck.processes.signals import install_cancel_handlers
|
|
42
|
+
from pytaskwirecheck.tui.app import LiveDashboard
|
|
43
|
+
from pytaskwirecheck.tui.renderer import PlainReporter
|
|
44
|
+
|
|
45
|
+
EXIT_OK = 0
|
|
46
|
+
EXIT_TASK_FAILURE = 1
|
|
47
|
+
EXIT_USAGE = 2
|
|
48
|
+
EXIT_GRAPH = 3
|
|
49
|
+
EXIT_INTERRUPTED = 130
|
|
50
|
+
|
|
51
|
+
_STARTER_CONFIG = """\
|
|
52
|
+
[project]
|
|
53
|
+
name = "example-workflow"
|
|
54
|
+
version = 1
|
|
55
|
+
|
|
56
|
+
[execution]
|
|
57
|
+
max_workers = 4
|
|
58
|
+
default_timeout_seconds = 900
|
|
59
|
+
fail_fast = false
|
|
60
|
+
|
|
61
|
+
[tasks.greet]
|
|
62
|
+
command = ["python", "-c", "print('hello from PyTaskWireCheck')"]
|
|
63
|
+
description = "A trivial, dependency-free first task"
|
|
64
|
+
|
|
65
|
+
[tasks.describe]
|
|
66
|
+
command = ["python", "-c", "print('workspace ready')"]
|
|
67
|
+
depends_on = ["greet"]
|
|
68
|
+
description = "Runs after greet"
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _err(stream: TextIO, message: str) -> None:
|
|
73
|
+
try:
|
|
74
|
+
stream.write("pytaskwirecheck: error: %s\n" % message)
|
|
75
|
+
stream.flush()
|
|
76
|
+
except (BrokenPipeError, ValueError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Argument parser
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
86
|
+
parser = argparse.ArgumentParser(
|
|
87
|
+
prog="pytaskwirecheck",
|
|
88
|
+
description="Local-first, dependency-aware task orchestration.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--version", action="version", version="pytaskwirecheck %s" % __version__
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"-C",
|
|
95
|
+
"--root",
|
|
96
|
+
dest="root",
|
|
97
|
+
default=None,
|
|
98
|
+
help="project root (defaults to the config file's directory)",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--config",
|
|
102
|
+
dest="config",
|
|
103
|
+
default=None,
|
|
104
|
+
help="path to pytaskwirecheck.toml (defaults to nearest file)",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--no-color", action="store_true", help="disable ANSI colour output"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
111
|
+
sub.required = True
|
|
112
|
+
|
|
113
|
+
# init
|
|
114
|
+
p_init = sub.add_parser("init", help="create a starter configuration")
|
|
115
|
+
p_init.add_argument(
|
|
116
|
+
"--path", default=".", help="directory to create pytaskwirecheck.toml in"
|
|
117
|
+
)
|
|
118
|
+
p_init.add_argument(
|
|
119
|
+
"--force", action="store_true", help="overwrite an existing file"
|
|
120
|
+
)
|
|
121
|
+
p_init.set_defaults(func=cmd_init)
|
|
122
|
+
|
|
123
|
+
# validate
|
|
124
|
+
p_val = sub.add_parser("validate", help="validate configuration and graph")
|
|
125
|
+
p_val.add_argument(
|
|
126
|
+
"--json", action="store_true", help="emit machine-readable output"
|
|
127
|
+
)
|
|
128
|
+
p_val.set_defaults(func=cmd_validate)
|
|
129
|
+
|
|
130
|
+
# graph
|
|
131
|
+
p_graph = sub.add_parser("graph", help="display the task DAG")
|
|
132
|
+
p_graph.add_argument(
|
|
133
|
+
"--format",
|
|
134
|
+
choices=["text", "dot", "json"],
|
|
135
|
+
default="text",
|
|
136
|
+
help="output format",
|
|
137
|
+
)
|
|
138
|
+
p_graph.set_defaults(func=cmd_graph)
|
|
139
|
+
|
|
140
|
+
# plan
|
|
141
|
+
p_plan = sub.add_parser("plan", help="show the execution plan")
|
|
142
|
+
p_plan.add_argument(
|
|
143
|
+
"tasks", nargs="*", help="restrict to these tasks and their dependencies"
|
|
144
|
+
)
|
|
145
|
+
p_plan.add_argument("--json", action="store_true", help="emit JSON")
|
|
146
|
+
p_plan.set_defaults(func=cmd_plan)
|
|
147
|
+
|
|
148
|
+
# run
|
|
149
|
+
p_run = sub.add_parser("run", help="execute tasks")
|
|
150
|
+
p_run.add_argument("tasks", nargs="*", help="tasks to run (defaults to all)")
|
|
151
|
+
p_run.add_argument(
|
|
152
|
+
"--no-cache", action="store_true", help="disable cache reads and writes"
|
|
153
|
+
)
|
|
154
|
+
p_run.add_argument(
|
|
155
|
+
"--max-workers", type=int, default=None, help="override worker count"
|
|
156
|
+
)
|
|
157
|
+
p_run.add_argument(
|
|
158
|
+
"--fail-fast",
|
|
159
|
+
dest="fail_fast",
|
|
160
|
+
action="store_true",
|
|
161
|
+
default=None,
|
|
162
|
+
help="stop launching new tasks after the first failure",
|
|
163
|
+
)
|
|
164
|
+
p_run.add_argument(
|
|
165
|
+
"--no-fail-fast",
|
|
166
|
+
dest="fail_fast",
|
|
167
|
+
action="store_false",
|
|
168
|
+
help="allow unrelated branches to continue after a failure",
|
|
169
|
+
)
|
|
170
|
+
p_run.add_argument("--json", action="store_true", help="emit a JSON run summary")
|
|
171
|
+
p_run.add_argument(
|
|
172
|
+
"-q", "--quiet", action="store_true", help="suppress progress output"
|
|
173
|
+
)
|
|
174
|
+
p_run.add_argument(
|
|
175
|
+
"--no-output",
|
|
176
|
+
action="store_true",
|
|
177
|
+
help="do not stream task output to the console",
|
|
178
|
+
)
|
|
179
|
+
p_run.add_argument(
|
|
180
|
+
"--tui",
|
|
181
|
+
action="store_true",
|
|
182
|
+
help="show a live dashboard when attached to a TTY",
|
|
183
|
+
)
|
|
184
|
+
p_run.set_defaults(func=cmd_run)
|
|
185
|
+
|
|
186
|
+
# cache
|
|
187
|
+
p_cache = sub.add_parser("cache", help="inspect or clean the cache")
|
|
188
|
+
cache_sub = p_cache.add_subparsers(dest="cache_command", metavar="ACTION")
|
|
189
|
+
cache_sub.required = True
|
|
190
|
+
p_cache_status = cache_sub.add_parser("status", help="show cache statistics")
|
|
191
|
+
p_cache_status.add_argument("--json", action="store_true")
|
|
192
|
+
p_cache_status.set_defaults(func=cmd_cache_status)
|
|
193
|
+
p_cache_clean = cache_sub.add_parser("clean", help="remove cached data")
|
|
194
|
+
p_cache_clean.add_argument(
|
|
195
|
+
"--force", action="store_true", help="do not prompt for confirmation"
|
|
196
|
+
)
|
|
197
|
+
p_cache_clean.add_argument("--json", action="store_true")
|
|
198
|
+
p_cache_clean.set_defaults(func=cmd_cache_clean)
|
|
199
|
+
|
|
200
|
+
# explain
|
|
201
|
+
p_explain = sub.add_parser(
|
|
202
|
+
"explain", help="explain scheduling and cache status for a task"
|
|
203
|
+
)
|
|
204
|
+
p_explain.add_argument("task", help="task identifier")
|
|
205
|
+
p_explain.add_argument("--json", action="store_true")
|
|
206
|
+
p_explain.set_defaults(func=cmd_explain)
|
|
207
|
+
|
|
208
|
+
# history
|
|
209
|
+
p_history = sub.add_parser("history", help="show recent execution summaries")
|
|
210
|
+
p_history.add_argument(
|
|
211
|
+
"--limit", type=int, default=20, help="number of runs to show"
|
|
212
|
+
)
|
|
213
|
+
p_history.add_argument("--json", action="store_true")
|
|
214
|
+
p_history.set_defaults(func=cmd_history)
|
|
215
|
+
|
|
216
|
+
return parser
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Shared helpers
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _resolve_config_path(args: argparse.Namespace) -> Path:
|
|
225
|
+
if args.config:
|
|
226
|
+
return Path(args.config).resolve()
|
|
227
|
+
found = find_config(Path.cwd())
|
|
228
|
+
if found is None:
|
|
229
|
+
raise ConfigError(
|
|
230
|
+
"no pytaskwirecheck.toml found in %s or any parent directory" % Path.cwd()
|
|
231
|
+
)
|
|
232
|
+
return found
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _load(args: argparse.Namespace) -> WorkflowConfig:
|
|
236
|
+
config_path = _resolve_config_path(args)
|
|
237
|
+
return load_config(config_path, root_override=args.root)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _prepare_graph(
|
|
241
|
+
config: WorkflowConfig,
|
|
242
|
+
) -> TaskGraph:
|
|
243
|
+
graph = build_graph(config)
|
|
244
|
+
validate_graph(graph)
|
|
245
|
+
return graph
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _color_enabled(args: argparse.Namespace, stream: TextIO) -> bool:
|
|
249
|
+
if getattr(args, "no_color", False):
|
|
250
|
+
return False
|
|
251
|
+
if os.environ.get("NO_COLOR"):
|
|
252
|
+
return False
|
|
253
|
+
try:
|
|
254
|
+
return bool(stream.isatty())
|
|
255
|
+
except (ValueError, AttributeError):
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Commands
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
265
|
+
target_dir = Path(args.path).resolve()
|
|
266
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
config_path = target_dir / "pytaskwirecheck.toml"
|
|
268
|
+
if config_path.exists() and not args.force:
|
|
269
|
+
_err(sys.stderr, "%s already exists (use --force)" % config_path)
|
|
270
|
+
return EXIT_USAGE
|
|
271
|
+
config_path.write_text(_STARTER_CONFIG, encoding="utf-8")
|
|
272
|
+
print("Created %s" % config_path)
|
|
273
|
+
return EXIT_OK
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
277
|
+
config = _load(args)
|
|
278
|
+
graph = _prepare_graph(config)
|
|
279
|
+
plan = build_plan(graph)
|
|
280
|
+
if args.json:
|
|
281
|
+
print(
|
|
282
|
+
json.dumps(
|
|
283
|
+
{
|
|
284
|
+
"ok": True,
|
|
285
|
+
"project": config.project.name,
|
|
286
|
+
"config_version": config.project.version,
|
|
287
|
+
"task_count": len(config.tasks),
|
|
288
|
+
"tasks": sorted(config.tasks.keys()),
|
|
289
|
+
"levels": [list(level) for level in plan.levels],
|
|
290
|
+
},
|
|
291
|
+
indent=2,
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
print(
|
|
296
|
+
"configuration OK: %d task(s), %d level(s)"
|
|
297
|
+
% (len(config.tasks), len(plan.levels))
|
|
298
|
+
)
|
|
299
|
+
return EXIT_OK
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _graph_text(graph: TaskGraph) -> str:
|
|
303
|
+
lines: List[str] = []
|
|
304
|
+
for node in graph:
|
|
305
|
+
if node.depends_on:
|
|
306
|
+
deps = ", ".join(node.depends_on)
|
|
307
|
+
lines.append("%s -> %s" % (deps, node.id))
|
|
308
|
+
else:
|
|
309
|
+
lines.append("%s (no dependencies)" % node.id)
|
|
310
|
+
return "\n".join(lines)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _graph_dot(graph: TaskGraph, name: str) -> str:
|
|
314
|
+
lines = ["digraph %s {" % name]
|
|
315
|
+
for node in graph:
|
|
316
|
+
lines.append(' "%s";' % node.id)
|
|
317
|
+
for dep in node.depends_on:
|
|
318
|
+
lines.append(' "%s" -> "%s";' % (dep, node.id))
|
|
319
|
+
lines.append("}")
|
|
320
|
+
return "\n".join(lines)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def cmd_graph(args: argparse.Namespace) -> int:
|
|
324
|
+
config = _load(args)
|
|
325
|
+
graph = _prepare_graph(config)
|
|
326
|
+
if args.format == "dot":
|
|
327
|
+
print(_graph_dot(graph, config.project.name.replace("-", "_")))
|
|
328
|
+
elif args.format == "json":
|
|
329
|
+
payload = {
|
|
330
|
+
"tasks": {node.id: {"depends_on": list(node.depends_on)} for node in graph}
|
|
331
|
+
}
|
|
332
|
+
print(json.dumps(payload, indent=2))
|
|
333
|
+
else:
|
|
334
|
+
print(_graph_text(graph))
|
|
335
|
+
return EXIT_OK
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def cmd_plan(args: argparse.Namespace) -> int:
|
|
339
|
+
config = _load(args)
|
|
340
|
+
graph = _prepare_graph(config)
|
|
341
|
+
selected = resolve_targets(graph, args.tasks)
|
|
342
|
+
plan = build_plan(graph, selected)
|
|
343
|
+
if args.json:
|
|
344
|
+
payload = {
|
|
345
|
+
"project": config.project.name,
|
|
346
|
+
"order": list(plan.order),
|
|
347
|
+
"levels": [list(level) for level in plan.levels],
|
|
348
|
+
"max_parallelism": plan.max_parallelism,
|
|
349
|
+
"tasks": [
|
|
350
|
+
{
|
|
351
|
+
"id": t.id,
|
|
352
|
+
"dependencies": list(t.dependencies),
|
|
353
|
+
"level": t.level,
|
|
354
|
+
"cache_eligible": t.cache_eligible,
|
|
355
|
+
}
|
|
356
|
+
for t in plan.tasks
|
|
357
|
+
],
|
|
358
|
+
}
|
|
359
|
+
print(json.dumps(payload, indent=2))
|
|
360
|
+
return EXIT_OK
|
|
361
|
+
print(
|
|
362
|
+
"Plan for %d task(s), max parallelism %d:"
|
|
363
|
+
% (len(plan.order), plan.max_parallelism)
|
|
364
|
+
)
|
|
365
|
+
for index, level in enumerate(plan.levels):
|
|
366
|
+
print(" level %d: %s" % (index, ", ".join(level)))
|
|
367
|
+
print("Order: %s" % " -> ".join(plan.order))
|
|
368
|
+
return EXIT_OK
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
372
|
+
config = _load(args)
|
|
373
|
+
graph = _prepare_graph(config)
|
|
374
|
+
try:
|
|
375
|
+
selected = resolve_targets(graph, args.tasks)
|
|
376
|
+
except GraphError as exc:
|
|
377
|
+
_err(sys.stderr, str(exc))
|
|
378
|
+
return EXIT_GRAPH
|
|
379
|
+
|
|
380
|
+
execution = config.execution
|
|
381
|
+
if args.max_workers is not None:
|
|
382
|
+
if args.max_workers < 1:
|
|
383
|
+
_err(sys.stderr, "--max-workers must be >= 1")
|
|
384
|
+
return EXIT_USAGE
|
|
385
|
+
execution = type(execution)(
|
|
386
|
+
max_workers=args.max_workers,
|
|
387
|
+
default_timeout_seconds=execution.default_timeout_seconds,
|
|
388
|
+
fail_fast=execution.fail_fast,
|
|
389
|
+
)
|
|
390
|
+
if args.fail_fast is not None:
|
|
391
|
+
execution = type(execution)(
|
|
392
|
+
max_workers=execution.max_workers,
|
|
393
|
+
default_timeout_seconds=execution.default_timeout_seconds,
|
|
394
|
+
fail_fast=args.fail_fast,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
run_id = uuid.uuid4().hex[:12]
|
|
398
|
+
events = EventBus()
|
|
399
|
+
cancel_token = CancellationToken()
|
|
400
|
+
|
|
401
|
+
use_cache = config.cache.enabled and not args.no_cache
|
|
402
|
+
cache_store = build_store(config) if config.cache.enabled else None
|
|
403
|
+
|
|
404
|
+
artifacts = ArtifactWriter(config.project.root, run_id)
|
|
405
|
+
structured = StructuredLogger(config.project.root, run_id)
|
|
406
|
+
reporter = PlainReporter(
|
|
407
|
+
sys.stdout,
|
|
408
|
+
color=_color_enabled(args, sys.stdout),
|
|
409
|
+
quiet=args.quiet or args.json,
|
|
410
|
+
show_output=not args.no_output,
|
|
411
|
+
)
|
|
412
|
+
events.subscribe(artifacts.handle)
|
|
413
|
+
events.subscribe(structured.handle)
|
|
414
|
+
use_tui = args.tui and not args.json
|
|
415
|
+
dashboard = None
|
|
416
|
+
if use_tui:
|
|
417
|
+
dashboard = LiveDashboard(sys.stdout, color=_color_enabled(args, sys.stdout))
|
|
418
|
+
events.subscribe(dashboard.handle)
|
|
419
|
+
elif not args.json:
|
|
420
|
+
events.subscribe(reporter.handle)
|
|
421
|
+
|
|
422
|
+
executor = TaskExecutor(
|
|
423
|
+
root=config.project.root,
|
|
424
|
+
default_timeout=execution.default_timeout_seconds,
|
|
425
|
+
events=events,
|
|
426
|
+
cancel_token=cancel_token,
|
|
427
|
+
cache=cache_store,
|
|
428
|
+
)
|
|
429
|
+
scheduler = Scheduler(
|
|
430
|
+
graph=graph,
|
|
431
|
+
execution=execution,
|
|
432
|
+
executor=executor,
|
|
433
|
+
events=events,
|
|
434
|
+
cancel_token=cancel_token,
|
|
435
|
+
use_cache=use_cache,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
result: Optional[RunResult] = None
|
|
439
|
+
try:
|
|
440
|
+
if dashboard is not None:
|
|
441
|
+
dashboard.start()
|
|
442
|
+
with install_cancel_handlers(cancel_token.cancel):
|
|
443
|
+
result = scheduler.run(selected, run_id=run_id)
|
|
444
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
445
|
+
cancel_token.cancel("interrupted")
|
|
446
|
+
return EXIT_INTERRUPTED
|
|
447
|
+
finally:
|
|
448
|
+
if dashboard is not None:
|
|
449
|
+
dashboard.stop()
|
|
450
|
+
artifacts.close()
|
|
451
|
+
structured.close()
|
|
452
|
+
|
|
453
|
+
assert result is not None
|
|
454
|
+
|
|
455
|
+
_record_history(config, result)
|
|
456
|
+
_send_notifications(config, result)
|
|
457
|
+
|
|
458
|
+
if args.json:
|
|
459
|
+
print(json.dumps(result.summary(), indent=2))
|
|
460
|
+
else:
|
|
461
|
+
log_path = artifacts.path_for(next(iter(sorted(selected))))
|
|
462
|
+
if log_path is not None and not args.quiet:
|
|
463
|
+
print("Logs: %s" % log_path.parent)
|
|
464
|
+
|
|
465
|
+
if result.cancelled and not result.succeeded:
|
|
466
|
+
return EXIT_INTERRUPTED
|
|
467
|
+
return EXIT_OK if result.succeeded else EXIT_TASK_FAILURE
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
# Cache and explain commands
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def cmd_cache_status(args: argparse.Namespace) -> int:
|
|
476
|
+
config = _load(args)
|
|
477
|
+
store = build_store(config)
|
|
478
|
+
status = store.status()
|
|
479
|
+
if args.json:
|
|
480
|
+
print(json.dumps(status, indent=2))
|
|
481
|
+
else:
|
|
482
|
+
print("Cache location: %s" % status["location"])
|
|
483
|
+
print("Entries: %d" % status["entries"])
|
|
484
|
+
print("Size: %.2f KiB" % (status["size_bytes"] / 1024.0))
|
|
485
|
+
if status["corrupted"]:
|
|
486
|
+
print("Corrupted entries: %d" % status["corrupted"])
|
|
487
|
+
return EXIT_OK
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def cmd_cache_clean(args: argparse.Namespace) -> int:
|
|
491
|
+
config = _load(args)
|
|
492
|
+
store = build_store(config)
|
|
493
|
+
if not args.force:
|
|
494
|
+
try:
|
|
495
|
+
interactive = sys.stdin.isatty()
|
|
496
|
+
except (ValueError, AttributeError):
|
|
497
|
+
interactive = False
|
|
498
|
+
if not interactive:
|
|
499
|
+
_err(
|
|
500
|
+
sys.stderr,
|
|
501
|
+
"refusing to clean without --force in a non-interactive session",
|
|
502
|
+
)
|
|
503
|
+
return EXIT_USAGE
|
|
504
|
+
answer = input("Remove all cached data under %s? [y/N] " % store.location)
|
|
505
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
506
|
+
print("Aborted.")
|
|
507
|
+
return EXIT_OK
|
|
508
|
+
result = store.clean()
|
|
509
|
+
if args.json:
|
|
510
|
+
print(json.dumps(result, indent=2))
|
|
511
|
+
else:
|
|
512
|
+
print("Removed %.2f KiB" % (result["removed_bytes"] / 1024.0))
|
|
513
|
+
return EXIT_OK
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def cmd_explain(args: argparse.Namespace) -> int:
|
|
517
|
+
config = _load(args)
|
|
518
|
+
graph = _prepare_graph(config)
|
|
519
|
+
if args.task not in graph.nodes:
|
|
520
|
+
_err(sys.stderr, "unknown task: %s" % args.task)
|
|
521
|
+
return EXIT_GRAPH
|
|
522
|
+
node = graph.get(args.task)
|
|
523
|
+
task = node.config
|
|
524
|
+
decision = decide(task, global_enabled=config.cache.enabled, cli_disabled=False)
|
|
525
|
+
info: dict = {
|
|
526
|
+
"task": task.id,
|
|
527
|
+
"description": task.description,
|
|
528
|
+
"command": list(task.command),
|
|
529
|
+
"shell": task.shell,
|
|
530
|
+
"depends_on": list(node.depends_on),
|
|
531
|
+
"dependents": list(node.dependents),
|
|
532
|
+
"inputs": list(task.inputs),
|
|
533
|
+
"outputs": list(task.outputs),
|
|
534
|
+
"timeout_seconds": task.timeout_seconds,
|
|
535
|
+
"retry": {
|
|
536
|
+
"max_attempts": task.retry.max_attempts,
|
|
537
|
+
"delay_seconds": task.retry.delay_seconds,
|
|
538
|
+
"backoff": task.retry.backoff,
|
|
539
|
+
},
|
|
540
|
+
"cache": {"use_cache": decision.use_cache, "reason": decision.reason},
|
|
541
|
+
}
|
|
542
|
+
if decision.use_cache:
|
|
543
|
+
store = build_store(config)
|
|
544
|
+
dependency_keys = {dep: None for dep in node.depends_on}
|
|
545
|
+
detail: dict = store.explain(task, dependency_keys)
|
|
546
|
+
detail["note"] = "dependency cache keys assumed absent; a real run may differ"
|
|
547
|
+
info["cache_detail"] = detail
|
|
548
|
+
if args.json:
|
|
549
|
+
print(json.dumps(info, indent=2))
|
|
550
|
+
return EXIT_OK
|
|
551
|
+
|
|
552
|
+
print("Task: %s" % task.id)
|
|
553
|
+
if task.description:
|
|
554
|
+
print(" %s" % task.description)
|
|
555
|
+
print(" command: %s" % " ".join(task.command))
|
|
556
|
+
print(" depends on: %s" % (", ".join(node.depends_on) or "(none)"))
|
|
557
|
+
print(" inputs: %s" % (", ".join(task.inputs) or "(none)"))
|
|
558
|
+
print(" outputs: %s" % (", ".join(task.outputs) or "(none)"))
|
|
559
|
+
print(" cache: %s (%s)" % (decision.use_cache, decision.reason))
|
|
560
|
+
if decision.use_cache and "cache_detail" in info:
|
|
561
|
+
cache_detail: dict = info["cache_detail"]
|
|
562
|
+
print(
|
|
563
|
+
" cache key: %s"
|
|
564
|
+
% cache_detail.get("key", cache_detail.get("reason", "n/a"))
|
|
565
|
+
)
|
|
566
|
+
print(" cache hit: %s" % cache_detail.get("hit", False))
|
|
567
|
+
return EXIT_OK
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ---------------------------------------------------------------------------
|
|
571
|
+
# Persistence and notifications (best-effort, never change run outcome)
|
|
572
|
+
# ---------------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _record_history(config: WorkflowConfig, result: RunResult) -> None:
|
|
576
|
+
if not config.history.enabled:
|
|
577
|
+
return
|
|
578
|
+
store = HistoryStore(config.project.root, max_runs=config.history.max_runs)
|
|
579
|
+
try:
|
|
580
|
+
store.record_run(
|
|
581
|
+
result,
|
|
582
|
+
project=config.project.name,
|
|
583
|
+
config_version=config.project.version,
|
|
584
|
+
)
|
|
585
|
+
except HistoryError as exc:
|
|
586
|
+
# Persistence must never alter the run's exit code or success state.
|
|
587
|
+
_err(sys.stderr, "warning: could not record history: %s" % exc)
|
|
588
|
+
finally:
|
|
589
|
+
store.close()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _failed_tasks(result: RunResult) -> list:
|
|
593
|
+
failed = []
|
|
594
|
+
for task_id, record in sorted(result.records.items()):
|
|
595
|
+
if record.status.is_failure:
|
|
596
|
+
failed.append(
|
|
597
|
+
{
|
|
598
|
+
"task": task_id,
|
|
599
|
+
"status": record.status.value,
|
|
600
|
+
"exit_code": record.exit_code,
|
|
601
|
+
}
|
|
602
|
+
)
|
|
603
|
+
return failed
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _send_notifications(config: WorkflowConfig, result: RunResult) -> None:
|
|
607
|
+
settings = config.notifications
|
|
608
|
+
if not settings.enabled:
|
|
609
|
+
return
|
|
610
|
+
event = "success" if result.succeeded else "failure"
|
|
611
|
+
notifier = WebhookNotifier(
|
|
612
|
+
settings.url,
|
|
613
|
+
timeout_seconds=settings.timeout_seconds,
|
|
614
|
+
max_attempts=settings.max_attempts,
|
|
615
|
+
events=settings.on,
|
|
616
|
+
)
|
|
617
|
+
if not notifier.wants(event):
|
|
618
|
+
return
|
|
619
|
+
notification = RunNotification(
|
|
620
|
+
event=event,
|
|
621
|
+
run_id=result.run_id,
|
|
622
|
+
project=config.project.name,
|
|
623
|
+
succeeded=result.succeeded,
|
|
624
|
+
cancelled=result.cancelled,
|
|
625
|
+
duration_seconds=result.duration_seconds,
|
|
626
|
+
counts=result.counts,
|
|
627
|
+
failed_tasks=_failed_tasks(result),
|
|
628
|
+
)
|
|
629
|
+
try:
|
|
630
|
+
delivered = notifier.send(notification)
|
|
631
|
+
except Exception as exc: # defensive: never break the run
|
|
632
|
+
_err(sys.stderr, "warning: notification failed: %s" % type(exc).__name__)
|
|
633
|
+
return
|
|
634
|
+
if not delivered:
|
|
635
|
+
_err(sys.stderr, "warning: notification was not delivered")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def cmd_history(args: argparse.Namespace) -> int:
|
|
639
|
+
try:
|
|
640
|
+
config = _load(args)
|
|
641
|
+
root = config.project.root
|
|
642
|
+
max_runs = config.history.max_runs
|
|
643
|
+
except ConfigError:
|
|
644
|
+
root = Path.cwd()
|
|
645
|
+
max_runs = 500
|
|
646
|
+
store = HistoryStore(root, max_runs=max_runs)
|
|
647
|
+
try:
|
|
648
|
+
runs = store.recent(limit=args.limit)
|
|
649
|
+
finally:
|
|
650
|
+
store.close()
|
|
651
|
+
if args.json:
|
|
652
|
+
print(json.dumps(runs, indent=2))
|
|
653
|
+
return EXIT_OK
|
|
654
|
+
if not runs:
|
|
655
|
+
print("No execution history recorded.")
|
|
656
|
+
return EXIT_OK
|
|
657
|
+
for run in runs:
|
|
658
|
+
status = "ok" if run["succeeded"] else "failed"
|
|
659
|
+
print(
|
|
660
|
+
"%s %-6s %6.2fs %s"
|
|
661
|
+
% (run["run_id"], status, run["duration_seconds"], run["project"])
|
|
662
|
+
)
|
|
663
|
+
return EXIT_OK
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
# Entry point
|
|
668
|
+
# ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
672
|
+
parser = build_parser()
|
|
673
|
+
args = parser.parse_args(argv)
|
|
674
|
+
try:
|
|
675
|
+
return int(args.func(args))
|
|
676
|
+
except ConfigError as exc:
|
|
677
|
+
_err(sys.stderr, str(exc))
|
|
678
|
+
return EXIT_USAGE
|
|
679
|
+
except GraphError as exc:
|
|
680
|
+
_err(sys.stderr, str(exc))
|
|
681
|
+
return EXIT_GRAPH
|
|
682
|
+
except PyTaskWireCheckError as exc:
|
|
683
|
+
_err(sys.stderr, str(exc))
|
|
684
|
+
return EXIT_USAGE
|
|
685
|
+
except BrokenPipeError: # pragma: no cover
|
|
686
|
+
return EXIT_OK
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
if __name__ == "__main__": # pragma: no cover
|
|
690
|
+
raise SystemExit(main())
|
|
File without changes
|