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