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.
@@ -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: 44713c70e04e
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: 11748af0886c
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) as e:
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: set[str]) -> None:
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: ce21bb523d49
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'\{\{\s*(var|arg|env|tt)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
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'\{\{\s*dep\.([a-zA-Z_][a-zA-Z0-9_.-]*)\.outputs\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
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'\{\{\s*self\.(inputs|outputs)\.([a-zA-Z_][a-zA-Z0-9_]*|[0-9]+)\s*\}\}'
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(text: str | dict[str, Any], variables: dict[str, str]) -> str | dict[str, Any]:
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 [ "default", "min", "max" ]:
55
+ for field in ["default", "min", "max"]:
56
56
  if field in text[arg_name]:
57
- text[arg_name][field] = substitute_variables(text[arg_name][field], 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"] = [substitute_variables(c, variables) for c in 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(text: str, args: dict[str, Any], exported_args: set[str] | None = None) -> str:
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: 3d07a1b4e6bc
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: 1e537c8d579c
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 = ", ".join(available) if available else "(none - all outputs are anonymous)"
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