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,72 @@
1
+ """Configuration file discovery, parsing, and loading.
2
+
3
+ This module performs I/O only to *read* configuration. It never executes tasks
4
+ or performs network access. Discovery walks upward from a starting directory to
5
+ locate ``pytaskwirecheck.toml``, mirroring common tooling conventions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Optional
13
+
14
+ try: # Python 3.11+
15
+ import tomllib as _toml
16
+ except ModuleNotFoundError: # pragma: no cover - exercised on 3.9/3.10
17
+ import tomli as _toml
18
+
19
+ from pytaskwirecheck.config.schema import WorkflowConfig
20
+ from pytaskwirecheck.config.validation import build_workflow, normalize_root
21
+ from pytaskwirecheck.diagnostics.errors import ConfigError
22
+
23
+ CONFIG_FILENAME = "pytaskwirecheck.toml"
24
+ STATE_DIRNAME = ".pytaskwirecheck"
25
+
26
+
27
+ def find_config(start: Path) -> Optional[Path]:
28
+ """Return the nearest ``pytaskwirecheck.toml`` at or above ``start``, if any."""
29
+ current = start.resolve()
30
+ for directory in (current, *current.parents):
31
+ candidate = directory / CONFIG_FILENAME
32
+ if candidate.is_file():
33
+ return candidate
34
+ return None
35
+
36
+
37
+ def parse_toml(text: str, *, location: str) -> Dict[str, Any]:
38
+ """Parse TOML text into a dict, raising :class:`ConfigError` on failure."""
39
+ try:
40
+ return _toml.loads(text)
41
+ except Exception as exc: # tomllib/tomli raise TOMLDecodeError
42
+ raise ConfigError("invalid TOML: %s" % exc, location=location) from exc
43
+
44
+
45
+ def load_config(
46
+ config_path: Path, *, root_override: Optional[str] = None
47
+ ) -> WorkflowConfig:
48
+ """Load and validate a workflow configuration from ``config_path``."""
49
+ config_path = config_path.resolve()
50
+ if not config_path.is_file():
51
+ raise ConfigError("configuration file not found: %s" % config_path)
52
+
53
+ try:
54
+ text = config_path.read_text(encoding="utf-8")
55
+ except OSError as exc:
56
+ raise ConfigError("cannot read %s: %s" % (config_path, exc)) from exc
57
+
58
+ raw = parse_toml(text, location=str(config_path))
59
+
60
+ # Default root is the directory holding the config file; CLI overrides are
61
+ # constrained to remain inside the current working directory.
62
+ base = Path(os.getcwd())
63
+ if root_override is not None:
64
+ root = normalize_root(root_override, base)
65
+ else:
66
+ root = config_path.parent.resolve()
67
+ return build_workflow(raw, root, config_path)
68
+
69
+
70
+ def state_dir(root: Path) -> Path:
71
+ """Return the (possibly non-existent) PyTaskWireCheck state directory for a root."""
72
+ return root / STATE_DIRNAME
@@ -0,0 +1,170 @@
1
+ """Immutable data model for a validated PyTaskWireCheck workflow configuration.
2
+
3
+ These dataclasses are the single source of truth for configuration once it has
4
+ been parsed and validated. They contain no I/O and no behaviour beyond simple
5
+ derived queries.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import List, Mapping, Optional, Tuple
13
+
14
+ #: The configuration schema versions PyTaskWireCheck understands.
15
+ SUPPORTED_CONFIG_VERSIONS: Tuple[int, ...] = (1,)
16
+
17
+ #: The version written by ``pytaskwirecheck init`` and accepted by this release.
18
+ DEFAULT_CONFIG_VERSION = 1
19
+
20
+ #: Maximum number of workers we will ever create in a single run.
21
+ MAX_WORKERS_LIMIT = 256
22
+
23
+ #: Environment variable names that affect task results by default. Tasks may
24
+ #: add their own via the ``env`` table. See :mod:`pytaskwirecheck.cache.fingerprint`.
25
+ DEFAULT_RELEVANT_ENV: Tuple[str, ...] = (
26
+ "PATH",
27
+ "PYTHONPATH",
28
+ "PYTHONHASHSEED",
29
+ "LANG",
30
+ "LC_ALL",
31
+ "TZ",
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class RetryPolicy:
37
+ """Bounded retry behaviour for a task.
38
+
39
+ ``max_attempts`` counts the initial attempt, so ``1`` means "no retries".
40
+ """
41
+
42
+ max_attempts: int = 1
43
+ delay_seconds: float = 0.0
44
+ backoff: bool = False
45
+ max_delay_seconds: float = 60.0
46
+ retry_on_exit_codes: Optional[Tuple[int, ...]] = None
47
+
48
+ @property
49
+ def enabled(self) -> bool:
50
+ return self.max_attempts > 1
51
+
52
+ def delay_for_attempt(self, attempt: int) -> float:
53
+ """Return the delay (seconds) before ``attempt`` (1-based).
54
+
55
+ ``attempt`` is the upcoming attempt number; the delay before attempt 1
56
+ is always zero.
57
+ """
58
+ if attempt <= 1:
59
+ return 0.0
60
+ if not self.backoff:
61
+ return self.delay_seconds
62
+ delay = self.delay_seconds * (2 ** (attempt - 2))
63
+ return min(delay, self.max_delay_seconds)
64
+
65
+ def should_retry_exit_code(self, code: int) -> bool:
66
+ if self.retry_on_exit_codes is None:
67
+ return code != 0
68
+ return code in self.retry_on_exit_codes
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ResourceLimits:
73
+ """Optional, best-effort resource limits for a task process."""
74
+
75
+ max_output_bytes: Optional[int] = None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class TaskConfig:
80
+ """A single task definition from ``pytaskwirecheck.toml``."""
81
+
82
+ id: str
83
+ command: Tuple[str, ...]
84
+ depends_on: Tuple[str, ...] = ()
85
+ inputs: Tuple[str, ...] = ()
86
+ outputs: Tuple[str, ...] = ()
87
+ cache: bool = True
88
+ cwd: Optional[str] = None
89
+ env: Mapping[str, str] = field(default_factory=dict)
90
+ inherit_env: bool = True
91
+ timeout_seconds: Optional[float] = None
92
+ retry: RetryPolicy = field(default_factory=RetryPolicy)
93
+ shell: bool = False
94
+ description: str = ""
95
+ resources: ResourceLimits = field(default_factory=ResourceLimits)
96
+
97
+ @property
98
+ def cache_eligible(self) -> bool:
99
+ """Whether the task may produce/consume cache entries.
100
+
101
+ A task with no declared inputs and no declared outputs is treated as
102
+ non-cacheable by default, because PyTaskWireCheck cannot know what it touches.
103
+ """
104
+ if not self.cache:
105
+ return False
106
+ return bool(self.inputs or self.outputs)
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class ExecutionConfig:
111
+ """Global execution settings."""
112
+
113
+ max_workers: int = 1
114
+ default_timeout_seconds: Optional[float] = None
115
+ fail_fast: bool = False
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class CacheConfig:
120
+ """Global cache settings."""
121
+
122
+ enabled: bool = True
123
+ relevant_env: Tuple[str, ...] = DEFAULT_RELEVANT_ENV
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class NotificationsConfig:
128
+ """Optional webhook notification settings (disabled by default)."""
129
+
130
+ enabled: bool = False
131
+ url: str = ""
132
+ timeout_seconds: float = 5.0
133
+ max_attempts: int = 2
134
+ on: Tuple[str, ...] = ("failure",)
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class HistoryConfig:
139
+ """Local execution history settings."""
140
+
141
+ enabled: bool = True
142
+ max_runs: int = 500
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class ProjectConfig:
147
+ """Project-level metadata and the resolved workspace root."""
148
+
149
+ name: str
150
+ version: int
151
+ root: Path
152
+ config_path: Path
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class WorkflowConfig:
157
+ """A fully validated workflow: project metadata plus its tasks."""
158
+
159
+ project: ProjectConfig
160
+ execution: ExecutionConfig
161
+ tasks: Mapping[str, TaskConfig]
162
+ cache: CacheConfig = field(default_factory=CacheConfig)
163
+ history: HistoryConfig = field(default_factory=HistoryConfig)
164
+ notifications: NotificationsConfig = field(default_factory=NotificationsConfig)
165
+
166
+ def task_ids(self) -> List[str]:
167
+ return list(self.tasks.keys())
168
+
169
+ def get(self, task_id: str) -> TaskConfig:
170
+ return self.tasks[task_id]