tasktree 0.0.21__py3-none-any.whl → 0.0.23__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.
- tasktree/__init__.py +1 -1
- tasktree/cli.py +212 -119
- tasktree/console_logger.py +66 -0
- tasktree/docker.py +36 -23
- tasktree/executor.py +412 -240
- tasktree/graph.py +18 -13
- tasktree/hasher.py +18 -11
- tasktree/logging.py +112 -0
- tasktree/parser.py +237 -135
- tasktree/process_runner.py +411 -0
- tasktree/state.py +7 -8
- tasktree/substitution.py +29 -17
- tasktree/types.py +32 -15
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/METADATA +213 -18
- tasktree-0.0.23.dist-info/RECORD +17 -0
- tasktree-0.0.21.dist-info/RECORD +0 -14
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/WHEEL +0 -0
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Process execution abstraction layer.
|
|
2
|
+
|
|
3
|
+
This module provides an interface for running subprocesses, allowing for
|
|
4
|
+
better testability and dependency injection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from subprocess import Popen
|
|
12
|
+
from threading import Thread
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ProcessRunner",
|
|
17
|
+
"PassthroughProcessRunner",
|
|
18
|
+
"SilentProcessRunner",
|
|
19
|
+
"StdoutOnlyProcessRunner",
|
|
20
|
+
"StderrOnlyProcessRunner",
|
|
21
|
+
"StderrOnlyOnFailureProcessRunner",
|
|
22
|
+
"TaskOutputTypes",
|
|
23
|
+
"make_process_runner",
|
|
24
|
+
"stream_output"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
from tasktree.logging import Logger
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TaskOutputTypes(Enum):
|
|
31
|
+
"""
|
|
32
|
+
Enum defining task output control modes.
|
|
33
|
+
@athena: TBD
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
ALL = "all"
|
|
37
|
+
NONE = "none"
|
|
38
|
+
OUT = "out"
|
|
39
|
+
ERR = "err"
|
|
40
|
+
ON_ERR = "on-err"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ProcessRunner(ABC):
|
|
44
|
+
"""
|
|
45
|
+
Abstract interface for running subprocess commands.
|
|
46
|
+
@athena: 78720f594104
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
|
|
51
|
+
"""
|
|
52
|
+
Run a subprocess command.
|
|
53
|
+
|
|
54
|
+
This method signature matches subprocess.run() to allow for direct
|
|
55
|
+
substitution in existing code.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
*args: Positional arguments passed to subprocess.run
|
|
59
|
+
**kwargs: Keyword arguments passed to subprocess.run
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
subprocess.CompletedProcess: The completed process result
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
subprocess.CalledProcessError: If check=True and process exits non-zero
|
|
66
|
+
subprocess.TimeoutExpired: If timeout is exceeded
|
|
67
|
+
@athena: c056d217be2e
|
|
68
|
+
"""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PassthroughProcessRunner(ProcessRunner):
|
|
73
|
+
"""
|
|
74
|
+
Process runner that directly delegates to subprocess.run.
|
|
75
|
+
@athena: 470e2ca46355
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, logger: Logger) -> None:
|
|
79
|
+
self._logger = logger
|
|
80
|
+
|
|
81
|
+
def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
|
|
82
|
+
"""
|
|
83
|
+
Run a subprocess command via subprocess.run.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
*args: Positional arguments passed to subprocess.run
|
|
87
|
+
**kwargs: Keyword arguments passed to subprocess.run
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
subprocess.CompletedProcess: The completed process result
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
subprocess.CalledProcessError: If check=True and process exits non-zero
|
|
94
|
+
subprocess.TimeoutExpired: If timeout is exceeded
|
|
95
|
+
@athena: 9f6363a621f2
|
|
96
|
+
"""
|
|
97
|
+
return subprocess.run(*args, **kwargs)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SilentProcessRunner(ProcessRunner):
|
|
101
|
+
"""
|
|
102
|
+
Process runner that suppresses all subprocess output by redirecting to DEVNULL.
|
|
103
|
+
@athena: TBD
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, logger: Logger) -> None:
|
|
107
|
+
self._logger = logger
|
|
108
|
+
|
|
109
|
+
def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
|
|
110
|
+
"""
|
|
111
|
+
Run a subprocess command with stdout and stderr suppressed.
|
|
112
|
+
|
|
113
|
+
This implementation forces stdout=DEVNULL and stderr=DEVNULL to discard
|
|
114
|
+
all subprocess output, regardless of what the caller requests.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
*args: Positional arguments passed to subprocess.run
|
|
118
|
+
**kwargs: Keyword arguments passed to subprocess.run
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
subprocess.CompletedProcess: The completed process result
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
subprocess.CalledProcessError: If check=True and process exits non-zero
|
|
125
|
+
subprocess.TimeoutExpired: If timeout is exceeded
|
|
126
|
+
@athena: TBD
|
|
127
|
+
"""
|
|
128
|
+
kwargs["stdout"] = subprocess.DEVNULL
|
|
129
|
+
kwargs["stderr"] = subprocess.DEVNULL
|
|
130
|
+
return subprocess.run(*args, **kwargs)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def stream_output(pipe: Any, target: Any) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Stream output from a pipe to a target stream.
|
|
136
|
+
|
|
137
|
+
Handles exceptions gracefully to avoid silent thread failures.
|
|
138
|
+
If the pipe is closed or an error occurs during reading/writing,
|
|
139
|
+
the function returns without raising an exception.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
pipe: Input pipe to read from
|
|
143
|
+
target: Output stream to write to
|
|
144
|
+
@athena: TBD
|
|
145
|
+
"""
|
|
146
|
+
if pipe:
|
|
147
|
+
try:
|
|
148
|
+
for line in pipe:
|
|
149
|
+
target.write(line)
|
|
150
|
+
target.flush()
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
# Pipe closed or other I/O error - this is expected when
|
|
153
|
+
# process is killed or stdout is closed
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _start_thread_and_wait_to_complete(process: Popen[str], stream: Any, thread: Thread, process_allowed_runtime: float | None, logger: Logger) -> int:
|
|
158
|
+
join_timeout_secs = 1.0
|
|
159
|
+
|
|
160
|
+
thread.start()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
process_return_code = process.wait(timeout=process_allowed_runtime)
|
|
164
|
+
except subprocess.TimeoutExpired:
|
|
165
|
+
process.kill()
|
|
166
|
+
process.wait()
|
|
167
|
+
if stream:
|
|
168
|
+
stream.close()
|
|
169
|
+
stream = None
|
|
170
|
+
thread.join(timeout=join_timeout_secs)
|
|
171
|
+
raise
|
|
172
|
+
finally:
|
|
173
|
+
if stream:
|
|
174
|
+
stream.close()
|
|
175
|
+
stream = None
|
|
176
|
+
|
|
177
|
+
thread.join(timeout=join_timeout_secs)
|
|
178
|
+
if thread.is_alive():
|
|
179
|
+
logger.warn(f"Stream thread did not complete within timeout of {join_timeout_secs} seconds")
|
|
180
|
+
|
|
181
|
+
return process_return_code
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_result_if_necessary(raise_on_failure: bool, proc_ret_code: int, *args, **kwargs) -> subprocess.CompletedProcess[Any]:
|
|
185
|
+
if raise_on_failure and proc_ret_code != 0:
|
|
186
|
+
raise subprocess.CalledProcessError(
|
|
187
|
+
proc_ret_code, args[0] if args else kwargs.get("args", [])
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Return a CompletedProcess object for interface compatibility
|
|
191
|
+
return subprocess.CompletedProcess(
|
|
192
|
+
args=args[0] if args else kwargs.get("args", []),
|
|
193
|
+
returncode=proc_ret_code,
|
|
194
|
+
stdout=None,
|
|
195
|
+
stderr=None, # We streamed it, so don't capture it
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class StdoutOnlyProcessRunner(ProcessRunner):
|
|
200
|
+
"""
|
|
201
|
+
Process runner that streams stdout while suppressing stderr.
|
|
202
|
+
|
|
203
|
+
This implementation uses threading to asynchronously stream stdout from the
|
|
204
|
+
subprocess while discarding stderr output.
|
|
205
|
+
@athena: TBD
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self, logger: Logger) -> None:
|
|
209
|
+
self._logger = logger
|
|
210
|
+
|
|
211
|
+
def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
|
|
212
|
+
"""
|
|
213
|
+
Run a subprocess command with stdout streamed and stderr suppressed.
|
|
214
|
+
|
|
215
|
+
This implementation uses subprocess.Popen with threading to stream stdout
|
|
216
|
+
in real-time while discarding stderr. The interface remains synchronous
|
|
217
|
+
from the caller's perspective.
|
|
218
|
+
|
|
219
|
+
Buffering strategy: Uses line buffering (bufsize=1) to ensure output
|
|
220
|
+
appears promptly while maintaining reasonable performance.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
*args: Positional arguments passed to subprocess.Popen
|
|
224
|
+
**kwargs: Keyword arguments passed to subprocess.Popen
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
subprocess.CompletedProcess: The completed process result
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
subprocess.CalledProcessError: If check=True and process exits non-zero
|
|
231
|
+
subprocess.TimeoutExpired: If timeout is exceeded
|
|
232
|
+
@athena: TBD
|
|
233
|
+
"""
|
|
234
|
+
# Extract parameters that need special handling
|
|
235
|
+
check = kwargs.pop("check", False)
|
|
236
|
+
timeout = kwargs.pop("timeout", None)
|
|
237
|
+
# Remove capture_output if present - not supported by Popen
|
|
238
|
+
kwargs.pop("capture_output", None)
|
|
239
|
+
|
|
240
|
+
# Force stdout/stderr handling
|
|
241
|
+
kwargs["stdout"] = subprocess.PIPE
|
|
242
|
+
kwargs["stderr"] = subprocess.DEVNULL
|
|
243
|
+
kwargs["text"] = True
|
|
244
|
+
kwargs["bufsize"] = 1
|
|
245
|
+
|
|
246
|
+
# Start the process
|
|
247
|
+
process = subprocess.Popen(*args, **kwargs)
|
|
248
|
+
|
|
249
|
+
# Start thread to stream stdout with a descriptive name for debugging
|
|
250
|
+
thread = Thread(
|
|
251
|
+
target=stream_output,
|
|
252
|
+
args=(process.stdout, sys.stdout),
|
|
253
|
+
name="stdout-streamer",
|
|
254
|
+
daemon=True,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
process_return_code = _start_thread_and_wait_to_complete(process, process.stdout, thread, timeout, self._logger)
|
|
258
|
+
return _check_result_if_necessary(check, process_return_code, *args, **kwargs)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class StderrOnlyProcessRunner(ProcessRunner):
|
|
262
|
+
"""
|
|
263
|
+
Process runner that streams stderr while suppressing stdout.
|
|
264
|
+
|
|
265
|
+
This implementation uses threading to asynchronously stream stderr from the
|
|
266
|
+
subprocess while discarding stdout output.
|
|
267
|
+
@athena: TBD
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def __init__(self, logger: Logger) -> None:
|
|
271
|
+
self._logger = logger
|
|
272
|
+
|
|
273
|
+
def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
|
|
274
|
+
"""
|
|
275
|
+
Run a subprocess command with stderr streamed and stdout suppressed.
|
|
276
|
+
|
|
277
|
+
This implementation uses subprocess.Popen with threading to stream stderr
|
|
278
|
+
in real-time while discarding stdout. The interface remains synchronous
|
|
279
|
+
from the caller's perspective.
|
|
280
|
+
|
|
281
|
+
Buffering strategy: Uses line buffering (bufsize=1) to ensure output
|
|
282
|
+
appears promptly while maintaining reasonable performance.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
*args: Positional arguments passed to subprocess.Popen
|
|
286
|
+
**kwargs: Keyword arguments passed to subprocess.Popen
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
subprocess.CompletedProcess: The completed process result
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
subprocess.CalledProcessError: If check=True and process exits non-zero
|
|
293
|
+
subprocess.TimeoutExpired: If timeout is exceeded
|
|
294
|
+
@athena: TBD
|
|
295
|
+
"""
|
|
296
|
+
# Extract parameters that need special handling
|
|
297
|
+
check = kwargs.pop("check", False)
|
|
298
|
+
timeout = kwargs.pop("timeout", None)
|
|
299
|
+
# Remove capture_output if present - not supported by Popen
|
|
300
|
+
kwargs.pop("capture_output", None)
|
|
301
|
+
|
|
302
|
+
# Force stdout/stderr handling
|
|
303
|
+
kwargs["stdout"] = subprocess.DEVNULL
|
|
304
|
+
kwargs["stderr"] = subprocess.PIPE
|
|
305
|
+
kwargs["text"] = True
|
|
306
|
+
kwargs["bufsize"] = 1
|
|
307
|
+
|
|
308
|
+
# Start the process
|
|
309
|
+
process = subprocess.Popen(*args, **kwargs)
|
|
310
|
+
|
|
311
|
+
# Start thread to stream stderr with a descriptive name for debugging
|
|
312
|
+
thread = Thread(
|
|
313
|
+
target=stream_output,
|
|
314
|
+
args=(process.stderr, sys.stderr),
|
|
315
|
+
name="stderr-streamer",
|
|
316
|
+
daemon=True,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
process_return_code = _start_thread_and_wait_to_complete(process, process.stderr, thread, timeout, self._logger)
|
|
320
|
+
return _check_result_if_necessary(check, process_return_code, *args, **kwargs)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class StderrOnlyOnFailureProcessRunner(ProcessRunner):
|
|
324
|
+
"""
|
|
325
|
+
Process runner that buffers stderr and only outputs it on failure.
|
|
326
|
+
|
|
327
|
+
This implementation ignores stdout completely (sends to DEVNULL) and captures
|
|
328
|
+
stderr. The buffered stderr is only output if the process exits with a non-zero
|
|
329
|
+
code.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
def __init__(self, logger: Logger) -> None:
|
|
333
|
+
self._logger = logger
|
|
334
|
+
|
|
335
|
+
def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
|
|
336
|
+
"""
|
|
337
|
+
Run a subprocess command, buffering stderr and outputting only on failure.
|
|
338
|
+
|
|
339
|
+
Stdout is completely ignored (sent to DEVNULL). Stderr is collected in a
|
|
340
|
+
buffer during execution. If the process exits with non-zero code, the
|
|
341
|
+
buffered stderr is written to sys.stderr before returning/raising.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
*args: Positional arguments passed to subprocess.run
|
|
345
|
+
**kwargs: Keyword arguments passed to subprocess.run
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
subprocess.CompletedProcess: The completed process result
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
subprocess.CalledProcessError: If check=True and process exits non-zero
|
|
352
|
+
subprocess.TimeoutExpired: If timeout is exceeded
|
|
353
|
+
"""
|
|
354
|
+
check = kwargs.pop("check", False)
|
|
355
|
+
timeout = kwargs.pop("timeout", None)
|
|
356
|
+
kwargs.pop("capture_output", None) # Remove if present
|
|
357
|
+
kwargs.pop("stdout", None) # Remove if present
|
|
358
|
+
kwargs.pop("stderr", None) # Remove if present
|
|
359
|
+
|
|
360
|
+
result = subprocess.run(
|
|
361
|
+
*args,
|
|
362
|
+
**kwargs,
|
|
363
|
+
stdout=subprocess.DEVNULL,
|
|
364
|
+
stderr=subprocess.PIPE,
|
|
365
|
+
text=True,
|
|
366
|
+
timeout=timeout,
|
|
367
|
+
check=False,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if result.returncode != 0 and result.stderr:
|
|
371
|
+
sys.stderr.write(result.stderr)
|
|
372
|
+
sys.stderr.flush()
|
|
373
|
+
|
|
374
|
+
if check and result.returncode != 0:
|
|
375
|
+
raise subprocess.CalledProcessError(
|
|
376
|
+
result.returncode,
|
|
377
|
+
result.args,
|
|
378
|
+
output=result.stdout,
|
|
379
|
+
stderr=result.stderr,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def make_process_runner(output_type: TaskOutputTypes, logger: Logger) -> ProcessRunner:
|
|
386
|
+
"""
|
|
387
|
+
Factory function for creating ProcessRunner instances.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
output_type: The type of output control to use
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
ProcessRunner: A new ProcessRunner instance
|
|
394
|
+
|
|
395
|
+
Raises:
|
|
396
|
+
ValueError: If an invalid TaskOutputTypes value is provided
|
|
397
|
+
@athena: ba1d2e048716
|
|
398
|
+
"""
|
|
399
|
+
match output_type:
|
|
400
|
+
case TaskOutputTypes.ALL:
|
|
401
|
+
return PassthroughProcessRunner(logger)
|
|
402
|
+
case TaskOutputTypes.NONE:
|
|
403
|
+
return SilentProcessRunner(logger)
|
|
404
|
+
case TaskOutputTypes.OUT:
|
|
405
|
+
return StdoutOnlyProcessRunner(logger)
|
|
406
|
+
case TaskOutputTypes.ERR:
|
|
407
|
+
return StderrOnlyProcessRunner(logger)
|
|
408
|
+
case TaskOutputTypes.ON_ERR:
|
|
409
|
+
return StderrOnlyOnFailureProcessRunner(logger)
|
|
410
|
+
case _:
|
|
411
|
+
raise ValueError(f"Invalid TaskOutputTypes: {output_type}")
|
tasktree/state.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any, Set
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
@@ -43,7 +43,7 @@ class TaskState:
|
|
|
43
43
|
class StateManager:
|
|
44
44
|
"""
|
|
45
45
|
Manages the .tasktree-state file.
|
|
46
|
-
@athena:
|
|
46
|
+
@athena: 3dd3447bb53b
|
|
47
47
|
"""
|
|
48
48
|
|
|
49
49
|
STATE_FILE = ".tasktree-state"
|
|
@@ -64,17 +64,16 @@ class StateManager:
|
|
|
64
64
|
def load(self) -> None:
|
|
65
65
|
"""
|
|
66
66
|
Load state from file if it exists.
|
|
67
|
-
@athena:
|
|
67
|
+
@athena: e0cf9097c590
|
|
68
68
|
"""
|
|
69
69
|
if self.state_path.exists():
|
|
70
70
|
try:
|
|
71
71
|
with open(self.state_path, "r") as f:
|
|
72
72
|
data = json.load(f)
|
|
73
73
|
self._state = {
|
|
74
|
-
key: TaskState.from_dict(value)
|
|
75
|
-
for key, value in data.items()
|
|
74
|
+
key: TaskState.from_dict(value) for key, value in data.items()
|
|
76
75
|
}
|
|
77
|
-
except (json.JSONDecodeError, KeyError)
|
|
76
|
+
except (json.JSONDecodeError, KeyError):
|
|
78
77
|
# If state file is corrupted, start fresh
|
|
79
78
|
self._state = {}
|
|
80
79
|
self._loaded = True
|
|
@@ -116,13 +115,13 @@ class StateManager:
|
|
|
116
115
|
self.load()
|
|
117
116
|
self._state[cache_key] = state
|
|
118
117
|
|
|
119
|
-
def prune(self, valid_task_hashes:
|
|
118
|
+
def prune(self, valid_task_hashes: Set[str]) -> None:
|
|
120
119
|
"""
|
|
121
120
|
Remove state entries for tasks that no longer exist.
|
|
122
121
|
|
|
123
122
|
Args:
|
|
124
123
|
valid_task_hashes: Set of valid task hashes from current recipe
|
|
125
|
-
@athena:
|
|
124
|
+
@athena: 2717c6c244d3
|
|
126
125
|
"""
|
|
127
126
|
if not self._loaded:
|
|
128
127
|
self.load()
|
tasktree/substitution.py
CHANGED
|
@@ -7,30 +7,30 @@ and {{ env.NAME }} placeholders with their corresponding values.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import re
|
|
10
|
-
from random import choice
|
|
11
10
|
from typing import Any
|
|
12
11
|
|
|
13
|
-
|
|
14
12
|
# Pattern matches: {{ prefix.name }} with optional whitespace
|
|
15
13
|
# Groups: (1) prefix (var|arg|env|tt), (2) name (identifier)
|
|
16
14
|
PLACEHOLDER_PATTERN = re.compile(
|
|
17
|
-
r
|
|
15
|
+
r"\{\{\s*(var|arg|env|tt)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*}}"
|
|
18
16
|
)
|
|
19
17
|
|
|
20
18
|
# Pattern matches: {{ dep.task_name.outputs.output_name }} with optional whitespace
|
|
21
19
|
# Groups: (1) task_name (can include dots for namespacing), (2) output_name (identifier)
|
|
22
20
|
DEP_OUTPUT_PATTERN = re.compile(
|
|
23
|
-
r
|
|
21
|
+
r"\{\{\s*dep\.([a-zA-Z_][a-zA-Z0-9_.-]*)\.outputs\.([a-zA-Z_][a-zA-Z0-9_]*)\s*}}"
|
|
24
22
|
)
|
|
25
23
|
|
|
26
24
|
# Pattern matches: {{ self.(inputs|outputs).name }} or {{ self.(inputs|outputs).0 }} with optional whitespace
|
|
27
25
|
# Groups: (1) field (inputs|outputs), (2) name (identifier) or index (numeric)
|
|
28
26
|
SELF_REFERENCE_PATTERN = re.compile(
|
|
29
|
-
r
|
|
27
|
+
r"\{\{\s*self\.(inputs|outputs)\.([a-zA-Z_][a-zA-Z0-9_]*|[0-9]+)\s*}}"
|
|
30
28
|
)
|
|
31
29
|
|
|
32
30
|
|
|
33
|
-
def substitute_variables(
|
|
31
|
+
def substitute_variables(
|
|
32
|
+
text: str | dict[str, Any], variables: dict[str, str]
|
|
33
|
+
) -> str | dict[str, Any]:
|
|
34
34
|
"""
|
|
35
35
|
Substitute {{ var.name }} placeholders with variable values.
|
|
36
36
|
|
|
@@ -52,13 +52,18 @@ def substitute_variables(text: str | dict[str, Any], variables: dict[str, str])
|
|
|
52
52
|
|
|
53
53
|
for arg_name in text.keys():
|
|
54
54
|
# Pull out and substitute the individual fields of an argument one at a time
|
|
55
|
-
for field in
|
|
55
|
+
for field in ["default", "min", "max"]:
|
|
56
56
|
if field in text[arg_name]:
|
|
57
|
-
text[arg_name][field] = substitute_variables(
|
|
57
|
+
text[arg_name][field] = substitute_variables(
|
|
58
|
+
text[arg_name][field], variables
|
|
59
|
+
)
|
|
58
60
|
|
|
59
61
|
# choices is a list of things
|
|
60
62
|
if "choices" in text[arg_name]:
|
|
61
|
-
text[arg_name]["choices"] = [
|
|
63
|
+
text[arg_name]["choices"] = [
|
|
64
|
+
substitute_variables(c, variables)
|
|
65
|
+
for c in text[arg_name]["choices"]
|
|
66
|
+
]
|
|
62
67
|
|
|
63
68
|
return text
|
|
64
69
|
else:
|
|
@@ -88,7 +93,9 @@ def substitute_variables(text: str | dict[str, Any], variables: dict[str, str])
|
|
|
88
93
|
return PLACEHOLDER_PATTERN.sub(replace_match, text)
|
|
89
94
|
|
|
90
95
|
|
|
91
|
-
def substitute_arguments(
|
|
96
|
+
def substitute_arguments(
|
|
97
|
+
text: str, args: dict[str, Any], exported_args: set[str] | None = None
|
|
98
|
+
) -> str:
|
|
92
99
|
"""
|
|
93
100
|
Substitute {{ arg.name }} placeholders with argument values.
|
|
94
101
|
|
|
@@ -172,9 +179,7 @@ def substitute_environment(text: str) -> str:
|
|
|
172
179
|
|
|
173
180
|
value = os.environ.get(name)
|
|
174
181
|
if value is None:
|
|
175
|
-
raise ValueError(
|
|
176
|
-
f"Environment variable '{name}' is not set"
|
|
177
|
-
)
|
|
182
|
+
raise ValueError(f"Environment variable '{name}' is not set")
|
|
178
183
|
|
|
179
184
|
return value
|
|
180
185
|
|
|
@@ -203,6 +208,7 @@ def substitute_builtin_variables(text: str, builtin_vars: dict[str, str]) -> str
|
|
|
203
208
|
'Root: /home/user/project'
|
|
204
209
|
@athena: 716250e3a71f
|
|
205
210
|
"""
|
|
211
|
+
|
|
206
212
|
def replace_match(match: re.Match) -> str:
|
|
207
213
|
prefix = match.group(1)
|
|
208
214
|
name = match.group(2)
|
|
@@ -226,7 +232,7 @@ def substitute_dependency_args(
|
|
|
226
232
|
template_value: str,
|
|
227
233
|
parent_task_name: str,
|
|
228
234
|
parent_args: dict[str, Any],
|
|
229
|
-
exported_args: set[str] | None = None
|
|
235
|
+
exported_args: set[str] | None = None,
|
|
230
236
|
) -> str:
|
|
231
237
|
"""
|
|
232
238
|
Substitute {{ arg.* }} templates in dependency argument values.
|
|
@@ -250,7 +256,7 @@ def substitute_dependency_args(
|
|
|
250
256
|
Example:
|
|
251
257
|
>>> substitute_dependency_args("{{ arg.mode }}", "build", {"mode": "debug"})
|
|
252
258
|
'debug'
|
|
253
|
-
@athena:
|
|
259
|
+
@athena: 4ffd5664e3ec
|
|
254
260
|
"""
|
|
255
261
|
# Check for disallowed placeholder types in dependency args
|
|
256
262
|
# Only {{ arg.* }} is allowed, not {{ var.* }}, {{ env.* }}, or {{ tt.* }}
|
|
@@ -355,8 +361,9 @@ def substitute_dependency_outputs(
|
|
|
355
361
|
... {"build": build_task}
|
|
356
362
|
... )
|
|
357
363
|
'Deploy dist/app.js'
|
|
358
|
-
@athena:
|
|
364
|
+
@athena: 3fbf79c15ee9
|
|
359
365
|
"""
|
|
366
|
+
|
|
360
367
|
def replacer(match: re.Match) -> str:
|
|
361
368
|
dep_task_name = match.group(1)
|
|
362
369
|
output_name = match.group(2)
|
|
@@ -383,7 +390,11 @@ def substitute_dependency_outputs(
|
|
|
383
390
|
# Look up the named output
|
|
384
391
|
if output_name not in dep_task._output_map:
|
|
385
392
|
available = list(dep_task._output_map.keys())
|
|
386
|
-
available_msg =
|
|
393
|
+
available_msg = (
|
|
394
|
+
", ".join(available)
|
|
395
|
+
if available
|
|
396
|
+
else "(none - all outputs are anonymous)"
|
|
397
|
+
)
|
|
387
398
|
raise ValueError(
|
|
388
399
|
f"Task '{current_task_name}' references output '{output_name}' "
|
|
389
400
|
f"from task '{dep_task_name}', but '{dep_task_name}' has no output named '{output_name}'.\n"
|
|
@@ -447,6 +458,7 @@ def substitute_self_references(
|
|
|
447
458
|
'cp *.txt out/result.txt'
|
|
448
459
|
@athena: 9d997ff08eef
|
|
449
460
|
"""
|
|
461
|
+
|
|
450
462
|
def replacer(match: re.Match) -> str:
|
|
451
463
|
field = match.group(1) # "inputs" or "outputs"
|
|
452
464
|
identifier = match.group(2) # name or numeric index
|