tasktree 0.0.20__py3-none-any.whl → 0.0.21__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/state.py CHANGED
@@ -10,13 +10,19 @@ from typing import Any
10
10
 
11
11
  @dataclass
12
12
  class TaskState:
13
- """State for a single task execution."""
13
+ """
14
+ State for a single task execution.
15
+ @athena: b08a937b7f2f
16
+ """
14
17
 
15
18
  last_run: float
16
19
  input_state: dict[str, float | str] = field(default_factory=dict)
17
20
 
18
21
  def to_dict(self) -> dict[str, Any]:
19
- """Convert to dictionary for JSON serialization."""
22
+ """
23
+ Convert to dictionary for JSON serialization.
24
+ @athena: 5f42efc35e77
25
+ """
20
26
  return {
21
27
  "last_run": self.last_run,
22
28
  "input_state": self.input_state,
@@ -24,7 +30,10 @@ class TaskState:
24
30
 
25
31
  @classmethod
26
32
  def from_dict(cls, data: dict[str, Any]) -> "TaskState":
27
- """Create from dictionary loaded from JSON."""
33
+ """
34
+ Create from dictionary loaded from JSON.
35
+ @athena: d9237db7e7e7
36
+ """
28
37
  return cls(
29
38
  last_run=data["last_run"],
30
39
  input_state=data.get("input_state", {}),
@@ -32,15 +41,20 @@ class TaskState:
32
41
 
33
42
 
34
43
  class StateManager:
35
- """Manages the .tasktree-state file."""
44
+ """
45
+ Manages the .tasktree-state file.
46
+ @athena: 44713c70e04e
47
+ """
36
48
 
37
49
  STATE_FILE = ".tasktree-state"
38
50
 
39
51
  def __init__(self, project_root: Path):
40
- """Initialize state manager.
52
+ """
53
+ Initialize state manager.
41
54
 
42
55
  Args:
43
- project_root: Root directory of the project
56
+ project_root: Root directory of the project
57
+ @athena: a0afbd8ae591
44
58
  """
45
59
  self.project_root = project_root
46
60
  self.state_path = project_root / self.STATE_FILE
@@ -48,7 +62,10 @@ class StateManager:
48
62
  self._loaded = False
49
63
 
50
64
  def load(self) -> None:
51
- """Load state from file if it exists."""
65
+ """
66
+ Load state from file if it exists.
67
+ @athena: 11748af0886c
68
+ """
52
69
  if self.state_path.exists():
53
70
  try:
54
71
  with open(self.state_path, "r") as f:
@@ -63,40 +80,49 @@ class StateManager:
63
80
  self._loaded = True
64
81
 
65
82
  def save(self) -> None:
66
- """Save state to file."""
83
+ """
84
+ Save state to file.
85
+ @athena: 11e4a9761e4d
86
+ """
67
87
  data = {key: value.to_dict() for key, value in self._state.items()}
68
88
  with open(self.state_path, "w") as f:
69
89
  json.dump(data, f, indent=2)
70
90
 
71
91
  def get(self, cache_key: str) -> TaskState | None:
72
- """Get state for a task.
92
+ """
93
+ Get state for a task.
73
94
 
74
95
  Args:
75
- cache_key: Cache key (task_hash or task_hash__args_hash)
96
+ cache_key: Cache key (task_hash or task_hash__args_hash)
76
97
 
77
98
  Returns:
78
- TaskState if found, None otherwise
99
+ TaskState if found, None otherwise
100
+ @athena: fe5b27e855eb
79
101
  """
80
102
  if not self._loaded:
81
103
  self.load()
82
104
  return self._state.get(cache_key)
83
105
 
84
106
  def set(self, cache_key: str, state: TaskState) -> None:
85
- """Set state for a task.
107
+ """
108
+ Set state for a task.
86
109
 
87
110
  Args:
88
- cache_key: Cache key (task_hash or task_hash__args_hash)
89
- state: TaskState to store
111
+ cache_key: Cache key (task_hash or task_hash__args_hash)
112
+ state: TaskState to store
113
+ @athena: 244f16ea0ebc
90
114
  """
91
115
  if not self._loaded:
92
116
  self.load()
93
117
  self._state[cache_key] = state
94
118
 
95
119
  def prune(self, valid_task_hashes: set[str]) -> None:
96
- """Remove state entries for tasks that no longer exist.
120
+ """
121
+ Remove state entries for tasks that no longer exist.
97
122
 
98
123
  Args:
99
- valid_task_hashes: Set of valid task hashes from current recipe
124
+ valid_task_hashes: Set of valid task hashes from current recipe
125
+ @athena: ce21bb523d49
100
126
  """
101
127
  if not self._loaded:
102
128
  self.load()
@@ -114,6 +140,9 @@ class StateManager:
114
140
  del self._state[key]
115
141
 
116
142
  def clear(self) -> None:
117
- """Clear all state (useful for testing)."""
143
+ """
144
+ Clear all state (useful for testing).
145
+ @athena: 3a92e36d9f83
146
+ """
118
147
  self._state = {}
119
148
  self._loaded = True
tasktree/substitution.py CHANGED
@@ -1,7 +1,9 @@
1
- """Placeholder substitution for variables, arguments, and environment variables.
1
+ """
2
+ Placeholder substitution for variables, arguments, and environment variables.
2
3
 
3
4
  This module provides functions to substitute {{ var.name }}, {{ arg.name }},
4
5
  and {{ env.NAME }} placeholders with their corresponding values.
6
+ @athena: f92441b6fff5
5
7
  """
6
8
 
7
9
  import re
@@ -21,25 +23,27 @@ DEP_OUTPUT_PATTERN = re.compile(
21
23
  r'\{\{\s*dep\.([a-zA-Z_][a-zA-Z0-9_.-]*)\.outputs\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
22
24
  )
23
25
 
24
- # Pattern matches: {{ self.(inputs|outputs).name }} with optional whitespace
25
- # Groups: (1) field (inputs|outputs), (2) name (identifier)
26
+ # Pattern matches: {{ self.(inputs|outputs).name }} or {{ self.(inputs|outputs).0 }} with optional whitespace
27
+ # Groups: (1) field (inputs|outputs), (2) name (identifier) or index (numeric)
26
28
  SELF_REFERENCE_PATTERN = re.compile(
27
- r'\{\{\s*self\.(inputs|outputs)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
29
+ r'\{\{\s*self\.(inputs|outputs)\.([a-zA-Z_][a-zA-Z0-9_]*|[0-9]+)\s*\}\}'
28
30
  )
29
31
 
30
32
 
31
33
  def substitute_variables(text: str | dict[str, Any], variables: dict[str, str]) -> str | dict[str, Any]:
32
- """Substitute {{ var.name }} placeholders with variable values.
34
+ """
35
+ Substitute {{ var.name }} placeholders with variable values.
33
36
 
34
37
  Args:
35
- text: Text containing {{ var.name }} placeholders, or an argument dict with elements to be substituted
36
- variables: Dictionary mapping variable names to their string values
38
+ text: Text containing {{ var.name }} placeholders, or an argument dict with elements to be substituted
39
+ variables: Dictionary mapping variable names to their string values
37
40
 
38
41
  Returns:
39
- Text with all {{ var.name }} placeholders replaced
42
+ Text with all {{ var.name }} placeholders replaced
40
43
 
41
44
  Raises:
42
- ValueError: If a referenced variable is not defined
45
+ ValueError: If a referenced variable is not defined
46
+ @athena: 93e0fbbe447c
43
47
  """
44
48
  if isinstance(text, dict):
45
49
  # The dict will only contain a single key, the value of this key should also be a dictionary, which contains
@@ -85,18 +89,20 @@ def substitute_variables(text: str | dict[str, Any], variables: dict[str, str])
85
89
 
86
90
 
87
91
  def substitute_arguments(text: str, args: dict[str, Any], exported_args: set[str] | None = None) -> str:
88
- """Substitute {{ arg.name }} placeholders with argument values.
92
+ """
93
+ Substitute {{ arg.name }} placeholders with argument values.
89
94
 
90
95
  Args:
91
- text: Text containing {{ arg.name }} placeholders
92
- args: Dictionary mapping argument names to their values
93
- exported_args: Set of argument names that are exported (not available for substitution)
96
+ text: Text containing {{ arg.name }} placeholders
97
+ args: Dictionary mapping argument names to their values
98
+ exported_args: Set of argument names that are exported (not available for substitution)
94
99
 
95
100
  Returns:
96
- Text with all {{ arg.name }} placeholders replaced
101
+ Text with all {{ arg.name }} placeholders replaced
97
102
 
98
103
  Raises:
99
- ValueError: If a referenced argument is not provided or is exported
104
+ ValueError: If a referenced argument is not provided or is exported
105
+ @athena: 39577c2b74a6
100
106
  """
101
107
  # Use empty set if None for cleaner handling
102
108
  exported_args = exported_args or set()
@@ -134,23 +140,25 @@ def substitute_arguments(text: str, args: dict[str, Any], exported_args: set[str
134
140
 
135
141
 
136
142
  def substitute_environment(text: str) -> str:
137
- """Substitute {{ env.NAME }} placeholders with environment variable values.
143
+ """
144
+ Substitute {{ env.NAME }} placeholders with environment variable values.
138
145
 
139
146
  Environment variables are read from os.environ at substitution time.
140
147
 
141
148
  Args:
142
- text: Text containing {{ env.NAME }} placeholders
149
+ text: Text containing {{ env.NAME }} placeholders
143
150
 
144
151
  Returns:
145
- Text with all {{ env.NAME }} placeholders replaced
152
+ Text with all {{ env.NAME }} placeholders replaced
146
153
 
147
154
  Raises:
148
- ValueError: If a referenced environment variable is not set
155
+ ValueError: If a referenced environment variable is not set
149
156
 
150
157
  Example:
151
- >>> os.environ['USER'] = 'alice'
152
- >>> substitute_environment("Hello {{ env.USER }}")
153
- 'Hello alice'
158
+ >>> os.environ['USER'] = 'alice'
159
+ >>> substitute_environment("Hello {{ env.USER }}")
160
+ 'Hello alice'
161
+ @athena: 4f2afd2e0da2
154
162
  """
155
163
  import os
156
164
 
@@ -174,24 +182,26 @@ def substitute_environment(text: str) -> str:
174
182
 
175
183
 
176
184
  def substitute_builtin_variables(text: str, builtin_vars: dict[str, str]) -> str:
177
- """Substitute {{ tt.name }} placeholders with built-in variable values.
185
+ """
186
+ Substitute {{ tt.name }} placeholders with built-in variable values.
178
187
 
179
188
  Built-in variables are system-provided values that tasks can reference.
180
189
 
181
190
  Args:
182
- text: Text containing {{ tt.name }} placeholders
183
- builtin_vars: Dictionary mapping built-in variable names to their string values
191
+ text: Text containing {{ tt.name }} placeholders
192
+ builtin_vars: Dictionary mapping built-in variable names to their string values
184
193
 
185
194
  Returns:
186
- Text with all {{ tt.name }} placeholders replaced
195
+ Text with all {{ tt.name }} placeholders replaced
187
196
 
188
197
  Raises:
189
- ValueError: If a referenced built-in variable is not defined
198
+ ValueError: If a referenced built-in variable is not defined
190
199
 
191
200
  Example:
192
- >>> builtin_vars = {'project_root': '/home/user/project', 'task_name': 'build'}
193
- >>> substitute_builtin_variables("Root: {{ tt.project_root }}", builtin_vars)
194
- 'Root: /home/user/project'
201
+ >>> builtin_vars = {'project_root': '/home/user/project', 'task_name': 'build'}
202
+ >>> substitute_builtin_variables("Root: {{ tt.project_root }}", builtin_vars)
203
+ 'Root: /home/user/project'
204
+ @athena: 716250e3a71f
195
205
  """
196
206
  def replace_match(match: re.Match) -> str:
197
207
  prefix = match.group(1)
@@ -218,27 +228,29 @@ def substitute_dependency_args(
218
228
  parent_args: dict[str, Any],
219
229
  exported_args: set[str] | None = None
220
230
  ) -> str:
221
- """Substitute {{ arg.* }} templates in dependency argument values.
231
+ """
232
+ Substitute {{ arg.* }} templates in dependency argument values.
222
233
 
223
234
  This function substitutes parent task's arguments into dependency argument
224
235
  templates. Only {{ arg.* }} placeholders are allowed in dependency arguments.
225
236
 
226
237
  Args:
227
- template_value: String that may contain {{ arg.* }} placeholders
228
- parent_task_name: Name of parent task (for error messages)
229
- parent_args: Parent task's argument values
230
- exported_args: Set of parent's exported argument names
238
+ template_value: String that may contain {{ arg.* }} placeholders
239
+ parent_task_name: Name of parent task (for error messages)
240
+ parent_args: Parent task's argument values
241
+ exported_args: Set of parent's exported argument names
231
242
 
232
243
  Returns:
233
- String with {{ arg.* }} placeholders substituted
244
+ String with {{ arg.* }} placeholders substituted
234
245
 
235
246
  Raises:
236
- ValueError: If template references undefined arg, uses exported arg,
237
- or contains non-arg placeholders ({{ var.* }}, {{ env.* }}, {{ tt.* }})
247
+ ValueError: If template references undefined arg, uses exported arg,
248
+ or contains non-arg placeholders ({{ var.* }}, {{ env.* }}, {{ tt.* }})
238
249
 
239
250
  Example:
240
- >>> substitute_dependency_args("{{ arg.mode }}", "build", {"mode": "debug"})
241
- 'debug'
251
+ >>> substitute_dependency_args("{{ arg.mode }}", "build", {"mode": "debug"})
252
+ 'debug'
253
+ @athena: 3d07a1b4e6bc
242
254
  """
243
255
  # Check for disallowed placeholder types in dependency args
244
256
  # Only {{ arg.* }} is allowed, not {{ var.* }}, {{ env.* }}, or {{ tt.* }}
@@ -282,21 +294,23 @@ def substitute_dependency_args(
282
294
 
283
295
 
284
296
  def substitute_all(text: str, variables: dict[str, str], args: dict[str, Any]) -> str:
285
- """Substitute all placeholder types: variables, arguments, environment.
297
+ """
298
+ Substitute all placeholder types: variables, arguments, environment.
286
299
 
287
300
  Substitution order: variables → arguments → environment.
288
301
  This allows variables to contain arg/env placeholders.
289
302
 
290
303
  Args:
291
- text: Text containing placeholders
292
- variables: Dictionary mapping variable names to their string values
293
- args: Dictionary mapping argument names to their values
304
+ text: Text containing placeholders
305
+ variables: Dictionary mapping variable names to their string values
306
+ args: Dictionary mapping argument names to their values
294
307
 
295
308
  Returns:
296
- Text with all placeholders replaced
309
+ Text with all placeholders replaced
297
310
 
298
311
  Raises:
299
- ValueError: If any referenced variable, argument, or environment variable is not defined
312
+ ValueError: If any referenced variable, argument, or environment variable is not defined
313
+ @athena: c3fe48d3df5a
300
314
  """
301
315
  text = substitute_variables(text, variables)
302
316
  text = substitute_arguments(text, args)
@@ -310,7 +324,8 @@ def substitute_dependency_outputs(
310
324
  current_task_deps: list[str],
311
325
  resolved_tasks: dict[str, Any],
312
326
  ) -> str:
313
- """Substitute {{ dep.<task>.outputs.<name> }} placeholders with dependency output paths.
327
+ """
328
+ Substitute {{ dep.<task>.outputs.<name> }} placeholders with dependency output paths.
314
329
 
315
330
  This function resolves references to named outputs from dependency tasks.
316
331
  It validates that:
@@ -319,27 +334,28 @@ def substitute_dependency_outputs(
319
334
  - The referenced output name exists in the dependency task
320
335
 
321
336
  Args:
322
- text: Text containing {{ dep.*.outputs.* }} placeholders
323
- current_task_name: Name of task being resolved (for error messages)
324
- current_task_deps: List of dependency task names for the current task
325
- resolved_tasks: Dictionary mapping task names to Task objects (already resolved)
337
+ text: Text containing {{ dep.*.outputs.* }} placeholders
338
+ current_task_name: Name of task being resolved (for error messages)
339
+ current_task_deps: List of dependency task names for the current task
340
+ resolved_tasks: Dictionary mapping task names to Task objects (already resolved)
326
341
 
327
342
  Returns:
328
- Text with all {{ dep.*.outputs.* }} placeholders replaced with output paths
343
+ Text with all {{ dep.*.outputs.* }} placeholders replaced with output paths
329
344
 
330
345
  Raises:
331
- ValueError: If referenced task doesn't exist, isn't a dependency,
332
- or doesn't have the named output
346
+ ValueError: If referenced task doesn't exist, isn't a dependency,
347
+ or doesn't have the named output
333
348
 
334
349
  Example:
335
- >>> # Assuming build task has output { bundle: "dist/app.js" }
336
- >>> substitute_dependency_outputs(
337
- ... "Deploy {{ dep.build.outputs.bundle }}",
338
- ... "deploy",
339
- ... ["build"],
340
- ... {"build": build_task}
341
- ... )
342
- 'Deploy dist/app.js'
350
+ >>> # Assuming build task has output { bundle: "dist/app.js" }
351
+ >>> substitute_dependency_outputs(
352
+ ... "Deploy {{ dep.build.outputs.bundle }}",
353
+ ... "deploy",
354
+ ... ["build"],
355
+ ... {"build": build_task}
356
+ ... )
357
+ 'Deploy dist/app.js'
358
+ @athena: 1e537c8d579c
343
359
  """
344
360
  def replacer(match: re.Match) -> str:
345
361
  dep_task_name = match.group(1)
@@ -385,63 +401,106 @@ def substitute_self_references(
385
401
  task_name: str,
386
402
  input_map: dict[str, str],
387
403
  output_map: dict[str, str],
404
+ indexed_inputs: list[str],
405
+ indexed_outputs: list[str],
388
406
  ) -> str:
389
- """Substitute {{ self.inputs.name }} and {{ self.outputs.name }} placeholders.
407
+ """
408
+ Substitute {{ self.inputs.name }} and {{ self.outputs.name }} placeholders.
409
+
410
+ This function resolves references to the task's own inputs and outputs.
411
+ Supports both named access ({{ self.inputs.name }}) and positional access
412
+ ({{ self.inputs.0 }}, {{ self.inputs.1 }}, etc.).
413
+
414
+ Named entries use the input_map/output_map dictionaries.
415
+ Positional entries use indexed_inputs/indexed_outputs lists (0-based indexing,
416
+ following YAML declaration order).
390
417
 
391
- This function resolves references to the task's own named inputs and outputs.
392
- Only named entries are accessible; anonymous inputs/outputs cannot be referenced.
393
418
  The substitution is literal string replacement - no glob expansion or path resolution.
394
419
 
395
420
  Args:
396
- text: Text containing {{ self.* }} placeholders
397
- task_name: Name of current task (for error messages)
398
- input_map: Dictionary mapping input names to path strings
399
- output_map: Dictionary mapping output names to path strings
421
+ text: Text containing {{ self.* }} placeholders
422
+ task_name: Name of current task (for error messages)
423
+ input_map: Dictionary mapping input names to path strings
424
+ output_map: Dictionary mapping output names to path strings
425
+ indexed_inputs: List of all inputs in YAML order
426
+ indexed_outputs: List of all outputs in YAML order
400
427
 
401
428
  Returns:
402
- Text with all {{ self.* }} placeholders replaced with literal path strings
429
+ Text with all {{ self.* }} placeholders replaced with literal path strings
403
430
 
404
431
  Raises:
405
- ValueError: If referenced name doesn't exist in input_map or output_map
432
+ ValueError: If referenced name doesn't exist or index is out of bounds
406
433
 
407
434
  Example:
408
- >>> input_map = {"src": "*.txt"}
409
- >>> output_map = {"dest": "out/result.txt"}
410
- >>> substitute_self_references(
411
- ... "cp {{ self.inputs.src }} {{ self.outputs.dest }}",
412
- ... "copy",
413
- ... input_map,
414
- ... output_map
415
- ... )
416
- 'cp *.txt out/result.txt'
435
+ >>> input_map = {"src": "*.txt"}
436
+ >>> output_map = {"dest": "out/result.txt"}
437
+ >>> indexed_inputs = ["*.txt"]
438
+ >>> indexed_outputs = ["out/result.txt"]
439
+ >>> substitute_self_references(
440
+ ... "cp {{ self.inputs.src }} {{ self.outputs.0 }}",
441
+ ... "copy",
442
+ ... input_map,
443
+ ... output_map,
444
+ ... indexed_inputs,
445
+ ... indexed_outputs
446
+ ... )
447
+ 'cp *.txt out/result.txt'
448
+ @athena: 9d997ff08eef
417
449
  """
418
450
  def replacer(match: re.Match) -> str:
419
451
  field = match.group(1) # "inputs" or "outputs"
420
- name = match.group(2)
452
+ identifier = match.group(2) # name or numeric index
421
453
 
422
- # Select appropriate map
423
- if field == "inputs":
424
- name_map = input_map
425
- field_display = "input"
426
- else: # field == "outputs"
427
- name_map = output_map
428
- field_display = "output"
429
-
430
- # Check if name exists in map
431
- if name not in name_map:
432
- available = list(name_map.keys())
433
- if available:
434
- available_msg = ", ".join(available)
435
- else:
436
- available_msg = f"(none - all {field} are anonymous)"
454
+ # Check if identifier is a numeric index
455
+ if identifier.isdigit():
456
+ # Positional access
457
+ index = int(identifier)
458
+ indexed_list = indexed_inputs if field == "inputs" else indexed_outputs
459
+ field_display = "input" if field == "inputs" else "output"
437
460
 
438
- raise ValueError(
439
- f"Task '{task_name}' references {field_display} '{name}' "
440
- f"but has no {field_display} named '{name}'.\n"
441
- f"Available named {field}: {available_msg}\n"
442
- f"Hint: Define named {field} like: {field}: [{{ {name}: 'path/to/file' }}]"
443
- )
461
+ # Check if list is empty
462
+ if len(indexed_list) == 0:
463
+ raise ValueError(
464
+ f"Task '{task_name}' references {field_display} index '{index}' "
465
+ f"but has no {field} defined"
466
+ )
467
+
468
+ # Check bounds
469
+ if index >= len(indexed_list):
470
+ max_index = len(indexed_list) - 1
471
+ raise ValueError(
472
+ f"Task '{task_name}' references {field_display} index '{index}' "
473
+ f"but only has {len(indexed_list)} {field} (indices 0-{max_index})"
474
+ )
475
+
476
+ return indexed_list[index]
477
+ else:
478
+ # Named access (existing logic)
479
+ name = identifier
480
+
481
+ # Select appropriate map
482
+ if field == "inputs":
483
+ name_map = input_map
484
+ field_display = "input"
485
+ else: # field == "outputs"
486
+ name_map = output_map
487
+ field_display = "output"
488
+
489
+ # Check if name exists in map
490
+ if name not in name_map:
491
+ available = list(name_map.keys())
492
+ if available:
493
+ available_msg = ", ".join(available)
494
+ else:
495
+ available_msg = f"(none - all {field} are anonymous)"
496
+
497
+ raise ValueError(
498
+ f"Task '{task_name}' references {field_display} '{name}' "
499
+ f"but has no {field_display} named '{name}'.\n"
500
+ f"Available named {field}: {available_msg}\n"
501
+ f"Hint: Define named {field} like: {field}: [{{ {name}: 'path/to/file' }}]"
502
+ )
444
503
 
445
- return name_map[name]
504
+ return name_map[name]
446
505
 
447
506
  return SELF_REFERENCE_PATTERN.sub(replacer, text)