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