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,590 @@
|
|
|
1
|
+
"""Strict validation helpers that turn raw configuration dicts into the typed
|
|
2
|
+
:mod:`pytaskwirecheck.config.schema` model.
|
|
3
|
+
|
|
4
|
+
The guiding rule is: never silently reinterpret invalid configuration. Unknown
|
|
5
|
+
fields, wrong types, out-of-workspace paths, and unsupported versions all raise
|
|
6
|
+
:class:`~pytaskwirecheck.diagnostics.errors.ConfigError` with an actionable location.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from pytaskwirecheck.config.schema import (
|
|
16
|
+
DEFAULT_CONFIG_VERSION,
|
|
17
|
+
DEFAULT_RELEVANT_ENV,
|
|
18
|
+
MAX_WORKERS_LIMIT,
|
|
19
|
+
SUPPORTED_CONFIG_VERSIONS,
|
|
20
|
+
CacheConfig,
|
|
21
|
+
ExecutionConfig,
|
|
22
|
+
HistoryConfig,
|
|
23
|
+
NotificationsConfig,
|
|
24
|
+
ProjectConfig,
|
|
25
|
+
ResourceLimits,
|
|
26
|
+
RetryPolicy,
|
|
27
|
+
TaskConfig,
|
|
28
|
+
WorkflowConfig,
|
|
29
|
+
)
|
|
30
|
+
from pytaskwirecheck.diagnostics.errors import ConfigError, ConfigVersionError
|
|
31
|
+
|
|
32
|
+
_TOP_LEVEL_KEYS = {
|
|
33
|
+
"project",
|
|
34
|
+
"execution",
|
|
35
|
+
"tasks",
|
|
36
|
+
"cache",
|
|
37
|
+
"history",
|
|
38
|
+
"notifications",
|
|
39
|
+
}
|
|
40
|
+
_PROJECT_KEYS = {"name", "version", "root"}
|
|
41
|
+
_EXECUTION_KEYS = {
|
|
42
|
+
"max_workers",
|
|
43
|
+
"default_timeout_seconds",
|
|
44
|
+
"fail_fast",
|
|
45
|
+
}
|
|
46
|
+
_CACHE_KEYS = {"enabled", "relevant_env"}
|
|
47
|
+
_HISTORY_KEYS = {"enabled", "max_runs"}
|
|
48
|
+
_NOTIFICATIONS_KEYS = {"enabled", "url", "timeout_seconds", "max_attempts", "on"}
|
|
49
|
+
_TASK_KEYS = {
|
|
50
|
+
"command",
|
|
51
|
+
"depends_on",
|
|
52
|
+
"inputs",
|
|
53
|
+
"outputs",
|
|
54
|
+
"cache",
|
|
55
|
+
"cwd",
|
|
56
|
+
"env",
|
|
57
|
+
"inherit_env",
|
|
58
|
+
"timeout_seconds",
|
|
59
|
+
"retries",
|
|
60
|
+
"retry",
|
|
61
|
+
"shell",
|
|
62
|
+
"description",
|
|
63
|
+
"max_output_bytes",
|
|
64
|
+
}
|
|
65
|
+
_RETRY_KEYS = {
|
|
66
|
+
"max_attempts",
|
|
67
|
+
"delay_seconds",
|
|
68
|
+
"backoff",
|
|
69
|
+
"max_delay_seconds",
|
|
70
|
+
"retry_on_exit_codes",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_VALID_TASK_ID = frozenset(
|
|
74
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _reject_unknown(
|
|
79
|
+
table: Mapping[str, Any],
|
|
80
|
+
allowed: set,
|
|
81
|
+
location: str,
|
|
82
|
+
*,
|
|
83
|
+
task: Optional[str] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
unknown = sorted(set(table.keys()) - allowed)
|
|
86
|
+
if unknown:
|
|
87
|
+
raise ConfigError(
|
|
88
|
+
"unknown field(s): %s" % ", ".join(unknown),
|
|
89
|
+
location=location,
|
|
90
|
+
task=task,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _require_table(
|
|
95
|
+
value: Any, location: str, *, task: Optional[str] = None
|
|
96
|
+
) -> Mapping[str, Any]:
|
|
97
|
+
if not isinstance(value, dict):
|
|
98
|
+
raise ConfigError(
|
|
99
|
+
"expected a table, got %s" % type(value).__name__,
|
|
100
|
+
location=location,
|
|
101
|
+
task=task,
|
|
102
|
+
)
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _as_str(value: Any, location: str, *, task: Optional[str] = None) -> str:
|
|
107
|
+
if not isinstance(value, str):
|
|
108
|
+
raise ConfigError(
|
|
109
|
+
"expected a string, got %s" % type(value).__name__,
|
|
110
|
+
location=location,
|
|
111
|
+
task=task,
|
|
112
|
+
)
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _as_str_list(
|
|
117
|
+
value: Any, location: str, *, task: Optional[str] = None
|
|
118
|
+
) -> Tuple[str, ...]:
|
|
119
|
+
if not isinstance(value, list):
|
|
120
|
+
raise ConfigError(
|
|
121
|
+
"expected an array of strings, got %s" % type(value).__name__,
|
|
122
|
+
location=location,
|
|
123
|
+
task=task,
|
|
124
|
+
)
|
|
125
|
+
out: List[str] = []
|
|
126
|
+
for index, item in enumerate(value):
|
|
127
|
+
if not isinstance(item, str):
|
|
128
|
+
raise ConfigError(
|
|
129
|
+
"entry %d is not a string" % index,
|
|
130
|
+
location="%s[%d]" % (location, index),
|
|
131
|
+
task=task,
|
|
132
|
+
)
|
|
133
|
+
if item == "":
|
|
134
|
+
raise ConfigError(
|
|
135
|
+
"entries must not be empty",
|
|
136
|
+
location="%s[%d]" % (location, index),
|
|
137
|
+
task=task,
|
|
138
|
+
)
|
|
139
|
+
out.append(item)
|
|
140
|
+
return tuple(out)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _as_bool(value: Any, location: str, *, task: Optional[str] = None) -> bool:
|
|
144
|
+
if not isinstance(value, bool):
|
|
145
|
+
raise ConfigError(
|
|
146
|
+
"expected a boolean, got %s" % type(value).__name__,
|
|
147
|
+
location=location,
|
|
148
|
+
task=task,
|
|
149
|
+
)
|
|
150
|
+
return value
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _as_int(
|
|
154
|
+
value: Any,
|
|
155
|
+
location: str,
|
|
156
|
+
*,
|
|
157
|
+
minimum: Optional[int] = None,
|
|
158
|
+
task: Optional[str] = None,
|
|
159
|
+
) -> int:
|
|
160
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
161
|
+
raise ConfigError(
|
|
162
|
+
"expected an integer, got %s" % type(value).__name__,
|
|
163
|
+
location=location,
|
|
164
|
+
task=task,
|
|
165
|
+
)
|
|
166
|
+
if minimum is not None and value < minimum:
|
|
167
|
+
raise ConfigError(
|
|
168
|
+
"must be >= %d, got %d" % (minimum, value),
|
|
169
|
+
location=location,
|
|
170
|
+
task=task,
|
|
171
|
+
)
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _as_number(
|
|
176
|
+
value: Any,
|
|
177
|
+
location: str,
|
|
178
|
+
*,
|
|
179
|
+
minimum: Optional[float] = None,
|
|
180
|
+
exclusive_minimum: Optional[float] = None,
|
|
181
|
+
task: Optional[str] = None,
|
|
182
|
+
) -> float:
|
|
183
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
184
|
+
raise ConfigError(
|
|
185
|
+
"expected a number, got %s" % type(value).__name__,
|
|
186
|
+
location=location,
|
|
187
|
+
task=task,
|
|
188
|
+
)
|
|
189
|
+
result = float(value)
|
|
190
|
+
if minimum is not None and result < minimum:
|
|
191
|
+
raise ConfigError(
|
|
192
|
+
"must be >= %s, got %s" % (minimum, result),
|
|
193
|
+
location=location,
|
|
194
|
+
task=task,
|
|
195
|
+
)
|
|
196
|
+
if exclusive_minimum is not None and result <= exclusive_minimum:
|
|
197
|
+
raise ConfigError(
|
|
198
|
+
"must be > %s, got %s" % (exclusive_minimum, result),
|
|
199
|
+
location=location,
|
|
200
|
+
task=task,
|
|
201
|
+
)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _validate_task_id(task_id: str, location: str) -> None:
|
|
206
|
+
if not task_id:
|
|
207
|
+
raise ConfigError("task identifier must not be empty", location=location)
|
|
208
|
+
bad = set(task_id) - _VALID_TASK_ID
|
|
209
|
+
if bad:
|
|
210
|
+
raise ConfigError(
|
|
211
|
+
"task identifier contains invalid character(s): %s"
|
|
212
|
+
% ", ".join(sorted(repr(c) for c in bad)),
|
|
213
|
+
location=location,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _ensure_relative_within_root(
|
|
218
|
+
rel: str, root: Path, location: str, *, task: Optional[str] = None
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Reject absolute paths and ``..`` escapes outside the workspace root."""
|
|
221
|
+
candidate = Path(rel)
|
|
222
|
+
if candidate.is_absolute():
|
|
223
|
+
raise ConfigError(
|
|
224
|
+
"path must be relative to the project root, got an absolute path: %s" % rel,
|
|
225
|
+
location=location,
|
|
226
|
+
task=task,
|
|
227
|
+
)
|
|
228
|
+
resolved = (root / candidate).resolve()
|
|
229
|
+
try:
|
|
230
|
+
resolved.relative_to(root)
|
|
231
|
+
except ValueError:
|
|
232
|
+
raise ConfigError(
|
|
233
|
+
"path escapes the project workspace: %s" % rel,
|
|
234
|
+
location=location,
|
|
235
|
+
task=task,
|
|
236
|
+
) from None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _build_project(raw: Mapping[str, Any], root: Path) -> ProjectConfig:
|
|
240
|
+
project = _require_table(raw.get("project", {}), "project")
|
|
241
|
+
_reject_unknown(project, _PROJECT_KEYS, "project")
|
|
242
|
+
name = _as_str(project.get("name", root.name), "project.name")
|
|
243
|
+
version = project.get("version", DEFAULT_CONFIG_VERSION)
|
|
244
|
+
if isinstance(version, bool) or not isinstance(version, int):
|
|
245
|
+
raise ConfigError(
|
|
246
|
+
"expected an integer, got %s" % type(version).__name__,
|
|
247
|
+
location="project.version",
|
|
248
|
+
)
|
|
249
|
+
if version not in SUPPORTED_CONFIG_VERSIONS:
|
|
250
|
+
raise ConfigVersionError(
|
|
251
|
+
"unsupported configuration version %r; supported versions: %s"
|
|
252
|
+
% (version, ", ".join(str(v) for v in SUPPORTED_CONFIG_VERSIONS)),
|
|
253
|
+
location="project.version",
|
|
254
|
+
)
|
|
255
|
+
return ProjectConfig(name=name, version=version, root=root, config_path=Path())
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _build_execution(raw: Mapping[str, Any]) -> ExecutionConfig:
|
|
259
|
+
table = _require_table(raw.get("execution", {}), "execution")
|
|
260
|
+
_reject_unknown(table, _EXECUTION_KEYS, "execution")
|
|
261
|
+
max_workers = _as_int(
|
|
262
|
+
table.get("max_workers", 1), "execution.max_workers", minimum=1
|
|
263
|
+
)
|
|
264
|
+
if max_workers > MAX_WORKERS_LIMIT:
|
|
265
|
+
raise ConfigError(
|
|
266
|
+
"max_workers must be <= %d, got %d" % (MAX_WORKERS_LIMIT, max_workers),
|
|
267
|
+
location="execution.max_workers",
|
|
268
|
+
)
|
|
269
|
+
default_timeout = table.get("default_timeout_seconds")
|
|
270
|
+
if default_timeout is not None:
|
|
271
|
+
default_timeout = _as_number(
|
|
272
|
+
default_timeout,
|
|
273
|
+
"execution.default_timeout_seconds",
|
|
274
|
+
exclusive_minimum=0.0,
|
|
275
|
+
)
|
|
276
|
+
fail_fast = _as_bool(table.get("fail_fast", False), "execution.fail_fast")
|
|
277
|
+
return ExecutionConfig(
|
|
278
|
+
max_workers=max_workers,
|
|
279
|
+
default_timeout_seconds=default_timeout,
|
|
280
|
+
fail_fast=fail_fast,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _build_cache(raw: Mapping[str, Any]) -> CacheConfig:
|
|
285
|
+
table = _require_table(raw.get("cache", {}), "cache")
|
|
286
|
+
_reject_unknown(table, _CACHE_KEYS, "cache")
|
|
287
|
+
enabled = _as_bool(table.get("enabled", True), "cache.enabled")
|
|
288
|
+
relevant_raw = table.get("relevant_env")
|
|
289
|
+
if relevant_raw is None:
|
|
290
|
+
relevant_env = DEFAULT_RELEVANT_ENV
|
|
291
|
+
else:
|
|
292
|
+
relevant_env = _as_str_list(relevant_raw, "cache.relevant_env")
|
|
293
|
+
return CacheConfig(enabled=enabled, relevant_env=tuple(relevant_env))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _build_history(raw: Mapping[str, Any]) -> HistoryConfig:
|
|
297
|
+
table = _require_table(raw.get("history", {}), "history")
|
|
298
|
+
_reject_unknown(table, _HISTORY_KEYS, "history")
|
|
299
|
+
enabled = _as_bool(table.get("enabled", True), "history.enabled")
|
|
300
|
+
max_runs = _as_int(table.get("max_runs", 500), "history.max_runs", minimum=1)
|
|
301
|
+
return HistoryConfig(enabled=enabled, max_runs=max_runs)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _build_notifications(raw: Mapping[str, Any]) -> NotificationsConfig:
|
|
305
|
+
table = _require_table(raw.get("notifications", {}), "notifications")
|
|
306
|
+
_reject_unknown(table, _NOTIFICATIONS_KEYS, "notifications")
|
|
307
|
+
enabled = _as_bool(table.get("enabled", False), "notifications.enabled")
|
|
308
|
+
url = _as_str(table.get("url", ""), "notifications.url")
|
|
309
|
+
if enabled and not url:
|
|
310
|
+
raise ConfigError(
|
|
311
|
+
"notifications.url is required when notifications are enabled",
|
|
312
|
+
location="notifications.url",
|
|
313
|
+
)
|
|
314
|
+
timeout = _as_number(
|
|
315
|
+
table.get("timeout_seconds", 5.0),
|
|
316
|
+
"notifications.timeout_seconds",
|
|
317
|
+
exclusive_minimum=0.0,
|
|
318
|
+
)
|
|
319
|
+
max_attempts = _as_int(
|
|
320
|
+
table.get("max_attempts", 2),
|
|
321
|
+
"notifications.max_attempts",
|
|
322
|
+
minimum=1,
|
|
323
|
+
)
|
|
324
|
+
on = _as_str_list(table.get("on", ["failure"]), "notifications.on")
|
|
325
|
+
allowed_events = {"start", "success", "failure"}
|
|
326
|
+
bad = [event for event in on if event not in allowed_events]
|
|
327
|
+
if bad:
|
|
328
|
+
raise ConfigError(
|
|
329
|
+
"unknown notification event(s): %s" % ", ".join(bad),
|
|
330
|
+
location="notifications.on",
|
|
331
|
+
)
|
|
332
|
+
return NotificationsConfig(
|
|
333
|
+
enabled=enabled,
|
|
334
|
+
url=url,
|
|
335
|
+
timeout_seconds=timeout,
|
|
336
|
+
max_attempts=max_attempts,
|
|
337
|
+
on=tuple(on),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _build_retry(
|
|
342
|
+
raw_value: Any, legacy_retries: Any, task: str, location: str
|
|
343
|
+
) -> RetryPolicy:
|
|
344
|
+
if raw_value is None:
|
|
345
|
+
if legacy_retries is None:
|
|
346
|
+
return RetryPolicy()
|
|
347
|
+
attempts = _as_int(legacy_retries, location, minimum=0, task=task)
|
|
348
|
+
# ``retries`` counts retries after the first attempt.
|
|
349
|
+
return RetryPolicy(max_attempts=attempts + 1)
|
|
350
|
+
|
|
351
|
+
table = _require_table(raw_value, location, task=task)
|
|
352
|
+
_reject_unknown(table, _RETRY_KEYS, location, task=task)
|
|
353
|
+
max_attempts = _as_int(
|
|
354
|
+
table.get("max_attempts", 1),
|
|
355
|
+
"%s.max_attempts" % location,
|
|
356
|
+
minimum=1,
|
|
357
|
+
task=task,
|
|
358
|
+
)
|
|
359
|
+
delay = _as_number(
|
|
360
|
+
table.get("delay_seconds", 0.0),
|
|
361
|
+
"%s.delay_seconds" % location,
|
|
362
|
+
minimum=0.0,
|
|
363
|
+
task=task,
|
|
364
|
+
)
|
|
365
|
+
backoff = _as_bool(table.get("backoff", False), "%s.backoff" % location, task=task)
|
|
366
|
+
max_delay = _as_number(
|
|
367
|
+
table.get("max_delay_seconds", 60.0),
|
|
368
|
+
"%s.max_delay_seconds" % location,
|
|
369
|
+
minimum=0.0,
|
|
370
|
+
task=task,
|
|
371
|
+
)
|
|
372
|
+
codes_raw = table.get("retry_on_exit_codes")
|
|
373
|
+
codes: Optional[Tuple[int, ...]] = None
|
|
374
|
+
if codes_raw is not None:
|
|
375
|
+
if not isinstance(codes_raw, list):
|
|
376
|
+
raise ConfigError(
|
|
377
|
+
"expected an array of integers",
|
|
378
|
+
location="%s.retry_on_exit_codes" % location,
|
|
379
|
+
task=task,
|
|
380
|
+
)
|
|
381
|
+
codes_list: List[int] = []
|
|
382
|
+
for index, code in enumerate(codes_raw):
|
|
383
|
+
if isinstance(code, bool) or not isinstance(code, int):
|
|
384
|
+
raise ConfigError(
|
|
385
|
+
"entry %d is not an integer" % index,
|
|
386
|
+
location="%s.retry_on_exit_codes[%d]" % (location, index),
|
|
387
|
+
task=task,
|
|
388
|
+
)
|
|
389
|
+
codes_list.append(code)
|
|
390
|
+
codes = tuple(codes_list)
|
|
391
|
+
return RetryPolicy(
|
|
392
|
+
max_attempts=max_attempts,
|
|
393
|
+
delay_seconds=delay,
|
|
394
|
+
backoff=backoff,
|
|
395
|
+
max_delay_seconds=max_delay,
|
|
396
|
+
retry_on_exit_codes=codes,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _build_task(task_id: str, raw: Any, root: Path) -> TaskConfig:
|
|
401
|
+
location = "tasks.%s" % task_id
|
|
402
|
+
_validate_task_id(task_id, location)
|
|
403
|
+
table = _require_table(raw, location, task=task_id)
|
|
404
|
+
_reject_unknown(table, _TASK_KEYS, location, task=task_id)
|
|
405
|
+
|
|
406
|
+
command = _as_str_list(table.get("command"), "%s.command" % location, task=task_id)
|
|
407
|
+
if not command:
|
|
408
|
+
raise ConfigError(
|
|
409
|
+
"command must not be empty",
|
|
410
|
+
location="%s.command" % location,
|
|
411
|
+
task=task_id,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
shell = _as_bool(table.get("shell", False), "%s.shell" % location, task=task_id)
|
|
415
|
+
if shell and len(command) != 1:
|
|
416
|
+
raise ConfigError(
|
|
417
|
+
"when shell = true, command must be a single string",
|
|
418
|
+
location="%s.command" % location,
|
|
419
|
+
task=task_id,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
depends_on = _as_str_list(
|
|
423
|
+
table.get("depends_on", []), "%s.depends_on" % location, task=task_id
|
|
424
|
+
)
|
|
425
|
+
if task_id in depends_on:
|
|
426
|
+
raise ConfigError(
|
|
427
|
+
"task must not depend on itself",
|
|
428
|
+
location="%s.depends_on" % location,
|
|
429
|
+
task=task_id,
|
|
430
|
+
)
|
|
431
|
+
if len(set(depends_on)) != len(depends_on):
|
|
432
|
+
raise ConfigError(
|
|
433
|
+
"duplicate entries in depends_on",
|
|
434
|
+
location="%s.depends_on" % location,
|
|
435
|
+
task=task_id,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
inputs = _as_str_list(table.get("inputs", []), "%s.inputs" % location, task=task_id)
|
|
439
|
+
for index, pattern in enumerate(inputs):
|
|
440
|
+
_ensure_relative_within_root(
|
|
441
|
+
pattern,
|
|
442
|
+
root,
|
|
443
|
+
"%s.inputs[%d]" % (location, index),
|
|
444
|
+
task=task_id,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
outputs = _as_str_list(
|
|
448
|
+
table.get("outputs", []), "%s.outputs" % location, task=task_id
|
|
449
|
+
)
|
|
450
|
+
for index, out in enumerate(outputs):
|
|
451
|
+
_ensure_relative_within_root(
|
|
452
|
+
out,
|
|
453
|
+
root,
|
|
454
|
+
"%s.outputs[%d]" % (location, index),
|
|
455
|
+
task=task_id,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
cwd = table.get("cwd")
|
|
459
|
+
if cwd is not None:
|
|
460
|
+
cwd = _as_str(cwd, "%s.cwd" % location, task=task_id)
|
|
461
|
+
_ensure_relative_within_root(cwd, root, "%s.cwd" % location, task=task_id)
|
|
462
|
+
|
|
463
|
+
env_raw = table.get("env", {})
|
|
464
|
+
env = _require_table(env_raw, "%s.env" % location, task=task_id)
|
|
465
|
+
env_out: Dict[str, str] = {}
|
|
466
|
+
for key, value in env.items():
|
|
467
|
+
if not isinstance(key, str) or not key:
|
|
468
|
+
raise ConfigError(
|
|
469
|
+
"environment variable names must be non-empty strings",
|
|
470
|
+
location="%s.env" % location,
|
|
471
|
+
task=task_id,
|
|
472
|
+
)
|
|
473
|
+
if not isinstance(value, str):
|
|
474
|
+
raise ConfigError(
|
|
475
|
+
"value for env var %r must be a string" % key,
|
|
476
|
+
location="%s.env.%s" % (location, key),
|
|
477
|
+
task=task_id,
|
|
478
|
+
)
|
|
479
|
+
env_out[key] = value
|
|
480
|
+
|
|
481
|
+
inherit_env = _as_bool(
|
|
482
|
+
table.get("inherit_env", True),
|
|
483
|
+
"%s.inherit_env" % location,
|
|
484
|
+
task=task_id,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
timeout = table.get("timeout_seconds")
|
|
488
|
+
if timeout is not None:
|
|
489
|
+
timeout = _as_number(
|
|
490
|
+
timeout,
|
|
491
|
+
"%s.timeout_seconds" % location,
|
|
492
|
+
exclusive_minimum=0.0,
|
|
493
|
+
task=task_id,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
retry = _build_retry(
|
|
497
|
+
table.get("retry"), table.get("retries"), task_id, "%s.retry" % location
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
max_output = table.get("max_output_bytes")
|
|
501
|
+
if max_output is not None:
|
|
502
|
+
max_output = _as_int(
|
|
503
|
+
max_output,
|
|
504
|
+
"%s.max_output_bytes" % location,
|
|
505
|
+
minimum=1,
|
|
506
|
+
task=task_id,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
cache = _as_bool(table.get("cache", True), "%s.cache" % location, task=task_id)
|
|
510
|
+
description = _as_str(
|
|
511
|
+
table.get("description", ""), "%s.description" % location, task=task_id
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
return TaskConfig(
|
|
515
|
+
id=task_id,
|
|
516
|
+
command=command,
|
|
517
|
+
depends_on=depends_on,
|
|
518
|
+
inputs=inputs,
|
|
519
|
+
outputs=outputs,
|
|
520
|
+
cache=cache,
|
|
521
|
+
cwd=cwd,
|
|
522
|
+
env=env_out,
|
|
523
|
+
inherit_env=inherit_env,
|
|
524
|
+
timeout_seconds=timeout,
|
|
525
|
+
retry=retry,
|
|
526
|
+
shell=shell,
|
|
527
|
+
description=description,
|
|
528
|
+
resources=ResourceLimits(max_output_bytes=max_output),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def build_workflow(
|
|
533
|
+
raw: Mapping[str, Any], root: Path, config_path: Path
|
|
534
|
+
) -> WorkflowConfig:
|
|
535
|
+
"""Validate ``raw`` and produce an immutable :class:`WorkflowConfig`."""
|
|
536
|
+
_reject_unknown(raw, _TOP_LEVEL_KEYS, "<root>")
|
|
537
|
+
|
|
538
|
+
project = _build_project(raw, root)
|
|
539
|
+
project = ProjectConfig(
|
|
540
|
+
name=project.name,
|
|
541
|
+
version=project.version,
|
|
542
|
+
root=root,
|
|
543
|
+
config_path=config_path,
|
|
544
|
+
)
|
|
545
|
+
execution = _build_execution(raw)
|
|
546
|
+
cache = _build_cache(raw)
|
|
547
|
+
history = _build_history(raw)
|
|
548
|
+
notifications = _build_notifications(raw)
|
|
549
|
+
|
|
550
|
+
tasks_raw = _require_table(raw.get("tasks", {}), "tasks")
|
|
551
|
+
if not tasks_raw:
|
|
552
|
+
raise ConfigError("no tasks defined", location="tasks")
|
|
553
|
+
tasks: Dict[str, TaskConfig] = {}
|
|
554
|
+
for task_id, task_raw in tasks_raw.items():
|
|
555
|
+
if task_id in tasks:
|
|
556
|
+
raise ConfigError(
|
|
557
|
+
"duplicate task identifier", location="tasks.%s" % task_id
|
|
558
|
+
)
|
|
559
|
+
tasks[task_id] = _build_task(task_id, task_raw, root)
|
|
560
|
+
|
|
561
|
+
return WorkflowConfig(
|
|
562
|
+
project=project,
|
|
563
|
+
execution=execution,
|
|
564
|
+
tasks=tasks,
|
|
565
|
+
cache=cache,
|
|
566
|
+
history=history,
|
|
567
|
+
notifications=notifications,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def normalize_root(raw_root: Optional[str], base: Path) -> Path:
|
|
572
|
+
"""Resolve the workspace root from CLI/config, constrained to ``base``."""
|
|
573
|
+
if raw_root is None:
|
|
574
|
+
return base.resolve()
|
|
575
|
+
candidate = Path(raw_root)
|
|
576
|
+
if not candidate.is_absolute():
|
|
577
|
+
candidate = base / candidate
|
|
578
|
+
resolved = candidate.resolve()
|
|
579
|
+
try:
|
|
580
|
+
resolved.relative_to(base.resolve())
|
|
581
|
+
except ValueError:
|
|
582
|
+
raise ConfigError(
|
|
583
|
+
"project root must be inside the working directory: %s" % raw_root
|
|
584
|
+
) from None
|
|
585
|
+
return resolved
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def environment_name() -> str:
|
|
589
|
+
"""Return a platform description for diagnostics (never the full env)."""
|
|
590
|
+
return os.name
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Diagnostic types for PyTaskWireCheck.
|
|
2
|
+
|
|
3
|
+
The error hierarchy lives in :mod:`pytaskwirecheck.diagnostics.errors` and is
|
|
4
|
+
re-exported here for convenience.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pytaskwirecheck.diagnostics.errors import (
|
|
8
|
+
CacheError,
|
|
9
|
+
ConfigError,
|
|
10
|
+
ConfigVersionError,
|
|
11
|
+
CycleError,
|
|
12
|
+
ExecutionError,
|
|
13
|
+
GraphError,
|
|
14
|
+
HistoryError,
|
|
15
|
+
NotificationError,
|
|
16
|
+
ProcessStartError,
|
|
17
|
+
PyTaskWireCheckError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"PyTaskWireCheckError",
|
|
22
|
+
"ConfigError",
|
|
23
|
+
"ConfigVersionError",
|
|
24
|
+
"GraphError",
|
|
25
|
+
"CycleError",
|
|
26
|
+
"ExecutionError",
|
|
27
|
+
"ProcessStartError",
|
|
28
|
+
"CacheError",
|
|
29
|
+
"HistoryError",
|
|
30
|
+
"NotificationError",
|
|
31
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Error types used across PyTaskWireCheck.
|
|
2
|
+
|
|
3
|
+
Every error carries enough context to diagnose the failure. Errors that relate
|
|
4
|
+
to a specific task include the task identifier; errors that relate to a
|
|
5
|
+
configuration location include a best-effort location string.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PyTaskWireCheckError(Exception):
|
|
14
|
+
"""Base class for all PyTaskWireCheck errors.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
message: Human-readable explanation of the failure.
|
|
18
|
+
task: Optional task identifier the error relates to.
|
|
19
|
+
location: Optional configuration location (e.g. ``tasks.test.inputs``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
*,
|
|
26
|
+
task: Optional[str] = None,
|
|
27
|
+
location: Optional[str] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message = message
|
|
31
|
+
self.task = task
|
|
32
|
+
self.location = location
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
parts = []
|
|
36
|
+
if self.task:
|
|
37
|
+
parts.append("task '%s'" % self.task)
|
|
38
|
+
if self.location:
|
|
39
|
+
parts.append("at %s" % self.location)
|
|
40
|
+
prefix = (" (" + ", ".join(parts) + ")") if parts else ""
|
|
41
|
+
return self.message + prefix
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigError(PyTaskWireCheckError):
|
|
45
|
+
"""Raised when the configuration file is malformed or invalid."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConfigVersionError(ConfigError):
|
|
49
|
+
"""Raised when the configuration declares an unsupported version."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GraphError(PyTaskWireCheckError):
|
|
53
|
+
"""Raised when the task graph is invalid (e.g. cycles, missing deps)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CycleError(GraphError):
|
|
57
|
+
"""Raised when the task graph contains a dependency cycle.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
cycle: The ordered cycle path, repeating the first node at the end.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, cycle: list) -> None:
|
|
64
|
+
self.cycle = list(cycle)
|
|
65
|
+
rendered = " -> ".join(self.cycle)
|
|
66
|
+
super().__init__("dependency cycle detected: %s" % rendered)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ExecutionError(PyTaskWireCheckError):
|
|
70
|
+
"""Raised for failures in the execution engine itself (not task failures)."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ProcessStartError(ExecutionError):
|
|
74
|
+
"""Raised when a subprocess cannot be started (missing binary, perms, ...)."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CacheError(PyTaskWireCheckError):
|
|
78
|
+
"""Raised for cache storage or integrity failures."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HistoryError(PyTaskWireCheckError):
|
|
82
|
+
"""Raised for execution history persistence failures."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class NotificationError(PyTaskWireCheckError):
|
|
86
|
+
"""Raised for notification delivery failures."""
|
|
File without changes
|