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/graph.py CHANGED
@@ -1,4 +1,7 @@
1
- """Dependency resolution using topological sorting."""
1
+ """
2
+ Dependency resolution using topological sorting.
3
+ @athena: bc19c5dc0cca
4
+ """
2
5
 
3
6
  from graphlib import TopologicalSorter
4
7
  from pathlib import Path
@@ -16,19 +19,21 @@ from tasktree.substitution import substitute_dependency_args
16
19
 
17
20
 
18
21
  def _get_exported_arg_names(task: Task) -> set[str]:
19
- """Extract names of exported arguments from a task.
22
+ """
23
+ Extract names of exported arguments from a task.
20
24
 
21
25
  Exported arguments are identified by the '$' prefix in their definition.
22
26
 
23
27
  Args:
24
- task: Task to extract exported arg names from
28
+ task: Task to extract exported arg names from
25
29
 
26
30
  Returns:
27
- Set of exported argument names (without the '$' prefix)
31
+ Set of exported argument names (without the '$' prefix)
28
32
 
29
33
  Example:
30
- Task with args: ["$server", "port"]
31
- Returns: {"server"}
34
+ Task with args: ["$server", "port"]
35
+ Returns: {"server"}
36
+ @athena: 706576271735
32
37
  """
33
38
  if not task.args:
34
39
  return set()
@@ -55,7 +60,8 @@ def resolve_dependency_invocation(
55
60
  parent_exported_args: set[str],
56
61
  recipe: Recipe
57
62
  ) -> DependencyInvocation:
58
- """Parse dependency specification and substitute parent argument templates.
63
+ """
64
+ Parse dependency specification and substitute parent argument templates.
59
65
 
60
66
  This function handles template substitution in dependency arguments. It:
61
67
  1. Checks if dependency arguments contain {{ arg.* }} templates
@@ -63,37 +69,38 @@ def resolve_dependency_invocation(
63
69
  3. Delegates to parse_dependency_spec for type conversion and validation
64
70
 
65
71
  Args:
66
- dep_spec: Dependency specification (str or dict with task name and args)
67
- parent_task_name: Name of the parent task (for error messages)
68
- parent_args: Parent task's argument values (for template substitution)
69
- parent_exported_args: Set of parent's exported argument names
70
- recipe: Recipe containing task definitions
72
+ dep_spec: Dependency specification (str or dict with task name and args)
73
+ parent_task_name: Name of the parent task (for error messages)
74
+ parent_args: Parent task's argument values (for template substitution)
75
+ parent_exported_args: Set of parent's exported argument names
76
+ recipe: Recipe containing task definitions
71
77
 
72
78
  Returns:
73
- DependencyInvocation with typed, validated arguments
79
+ DependencyInvocation with typed, validated arguments
74
80
 
75
81
  Raises:
76
- ValueError: If template substitution fails, argument validation fails,
77
- or dependency task doesn't exist
82
+ ValueError: If template substitution fails, argument validation fails,
83
+ or dependency task doesn't exist
78
84
 
79
85
  Examples:
80
- Simple string (no templates):
81
- >>> resolve_dependency_invocation("build", "test", {}, set(), recipe)
82
- DependencyInvocation("build", None)
83
-
84
- Literal arguments (no templates):
85
- >>> resolve_dependency_invocation({"build": ["debug"]}, "test", {}, set(), recipe)
86
- DependencyInvocation("build", {"mode": "debug"})
87
-
88
- Template substitution:
89
- >>> resolve_dependency_invocation(
90
- ... {"build": ["{{ arg.env }}"]},
91
- ... "test",
92
- ... {"env": "production"},
93
- ... set(),
94
- ... recipe
95
- ... )
96
- DependencyInvocation("build", {"mode": "production"})
86
+ Simple string (no templates):
87
+ >>> resolve_dependency_invocation("build", "test", {}, set(), recipe)
88
+ DependencyInvocation("build", None)
89
+
90
+ Literal arguments (no templates):
91
+ >>> resolve_dependency_invocation({"build": ["debug"]}, "test", {}, set(), recipe)
92
+ DependencyInvocation("build", {"mode": "debug"})
93
+
94
+ Template substitution:
95
+ >>> resolve_dependency_invocation(
96
+ ... {"build": ["{{ arg.env }}"]},
97
+ ... "test",
98
+ ... {"env": "production"},
99
+ ... set(),
100
+ ... recipe
101
+ ... )
102
+ DependencyInvocation("build", {"mode": "production"})
103
+ @athena: 968bae796809
97
104
  """
98
105
  # Simple string case - no args to substitute
99
106
  if isinstance(dep_spec, str):
@@ -163,30 +170,44 @@ def resolve_dependency_invocation(
163
170
 
164
171
 
165
172
  class CycleError(Exception):
166
- """Raised when a dependency cycle is detected."""
173
+ """
174
+ Raised when a dependency cycle is detected.
175
+ @athena: 80f584af9b92
176
+ """
167
177
 
168
178
  pass
169
179
 
170
180
 
171
181
  class TaskNotFoundError(Exception):
172
- """Raised when a task dependency doesn't exist."""
182
+ """
183
+ Raised when a task dependency doesn't exist.
184
+ @athena: f838e39564ae
185
+ """
173
186
 
174
187
  pass
175
188
 
176
189
 
177
190
  class TaskNode:
178
- """Represents a node in the dependency graph (task + arguments).
191
+ """
192
+ Represents a node in the dependency graph (task + arguments).
179
193
 
180
194
  Each node represents a unique invocation of a task with specific arguments.
181
195
  Tasks invoked with different arguments are considered different nodes.
196
+ @athena: b5ff009e2f60
182
197
  """
183
198
 
184
199
  def __init__(self, task_name: str, args: dict[str, Any] | None = None):
200
+ """
201
+ @athena: 24d686697ce3
202
+ """
185
203
  self.task_name = task_name
186
204
  self.args = args # Keep None as None
187
205
 
188
206
  def __hash__(self):
189
- """Hash based on task name and sorted args."""
207
+ """
208
+ Hash based on task name and sorted args.
209
+ @athena: 16615e007076
210
+ """
190
211
  # Treat None and {} as equivalent for hashing
191
212
  if not self.args:
192
213
  return hash(self.task_name)
@@ -194,7 +215,10 @@ class TaskNode:
194
215
  return hash((self.task_name, args_hash))
195
216
 
196
217
  def __eq__(self, other):
197
- """Equality based on task name and args."""
218
+ """
219
+ Equality based on task name and args.
220
+ @athena: 139d5234448f
221
+ """
198
222
  if not isinstance(other, TaskNode):
199
223
  return False
200
224
  # Treat None and {} as equivalent
@@ -203,12 +227,18 @@ class TaskNode:
203
227
  return self.task_name == other.task_name and self_args == other_args
204
228
 
205
229
  def __repr__(self):
230
+ """
231
+ @athena: dd99b6a7835e
232
+ """
206
233
  if not self.args:
207
234
  return f"TaskNode({self.task_name})"
208
235
  args_str = ", ".join(f"{k}={v}" for k, v in sorted(self.args.items()))
209
236
  return f"TaskNode({self.task_name}, {{{args_str}}})"
210
237
 
211
238
  def __str__(self):
239
+ """
240
+ @athena: 2d82ca4b9d41
241
+ """
212
242
  if not self.args:
213
243
  return self.task_name
214
244
  args_str = ", ".join(f"{k}={v}" for k, v in sorted(self.args.items()))
@@ -220,19 +250,21 @@ def resolve_execution_order(
220
250
  target_task: str,
221
251
  target_args: dict[str, Any] | None = None
222
252
  ) -> list[tuple[str, dict[str, Any]]]:
223
- """Resolve execution order for a task and its dependencies.
253
+ """
254
+ Resolve execution order for a task and its dependencies.
224
255
 
225
256
  Args:
226
- recipe: Parsed recipe containing all tasks
227
- target_task: Name of the task to execute
228
- target_args: Arguments for the target task (optional)
257
+ recipe: Parsed recipe containing all tasks
258
+ target_task: Name of the task to execute
259
+ target_args: Arguments for the target task (optional)
229
260
 
230
261
  Returns:
231
- List of (task_name, args_dict) tuples in execution order (dependencies first)
262
+ List of (task_name, args_dict) tuples in execution order (dependencies first)
232
263
 
233
264
  Raises:
234
- TaskNotFoundError: If target task or any dependency doesn't exist
235
- CycleError: If a dependency cycle is detected
265
+ TaskNotFoundError: If target task or any dependency doesn't exist
266
+ CycleError: If a dependency cycle is detected
267
+ @athena: b4443e1cb45d
236
268
  """
237
269
  if target_task not in recipe.tasks:
238
270
  raise TaskNotFoundError(f"Task not found: {target_task}")
@@ -310,24 +342,26 @@ def resolve_dependency_output_references(
310
342
  recipe: Recipe,
311
343
  ordered_tasks: list[tuple[str, dict[str, Any]]],
312
344
  ) -> None:
313
- """Resolve {{ dep.<task>.outputs.<name> }} references in topological order.
345
+ """
346
+ Resolve {{ dep.<task>.outputs.<name> }} references in topological order.
314
347
 
315
348
  This function walks through tasks in dependency order (dependencies first) and
316
349
  resolves any references to dependency outputs in task fields. Templates are
317
350
  resolved in place, modifying the Task objects in the recipe.
318
351
 
319
352
  Args:
320
- recipe: Recipe containing task definitions
321
- ordered_tasks: List of (task_name, args) tuples in topological order
353
+ recipe: Recipe containing task definitions
354
+ ordered_tasks: List of (task_name, args) tuples in topological order
322
355
 
323
356
  Raises:
324
- ValueError: If template references cannot be resolved (missing task,
325
- missing output, task not in dependencies, etc.)
357
+ ValueError: If template references cannot be resolved (missing task,
358
+ missing output, task not in dependencies, etc.)
326
359
 
327
360
  Example:
328
- Given tasks in topological order: [('build', {}), ('deploy', {})]
329
- If deploy.cmd contains "{{ dep.build.outputs.bundle }}", it will be
330
- resolved to the actual output path from the build task.
361
+ Given tasks in topological order: [('build', {}), ('deploy', {})]
362
+ If deploy.cmd contains "{{ dep.build.outputs.bundle }}", it will be
363
+ resolved to the actual output path from the build task.
364
+ @athena: 6dce9dbe6286
331
365
  """
332
366
  from tasktree.substitution import substitute_dependency_outputs
333
367
 
@@ -418,22 +452,29 @@ def resolve_self_references(
418
452
  recipe: Recipe,
419
453
  ordered_tasks: list[tuple[str, dict[str, Any]]],
420
454
  ) -> None:
421
- """Resolve {{ self.inputs.name }} and {{ self.outputs.name }} references.
455
+ """
456
+ Resolve {{ self.inputs.name }} and {{ self.inputs.0 }} style references.
422
457
 
423
458
  This function walks through tasks and resolves self-references to task's own
424
- inputs/outputs. Must be called AFTER resolve_dependency_output_references()
425
- so that dependency outputs are already resolved in output paths.
459
+ inputs/outputs. Supports both named access ({{ self.inputs.name }}) and
460
+ positional access ({{ self.inputs.0 }}, {{ self.inputs.1 }}, etc.).
461
+
462
+ Must be called AFTER resolve_dependency_output_references() so that
463
+ dependency outputs are already resolved in output paths.
426
464
 
427
465
  Args:
428
- recipe: Recipe containing task definitions
429
- ordered_tasks: List of (task_name, args) tuples in topological order
466
+ recipe: Recipe containing task definitions
467
+ ordered_tasks: List of (task_name, args) tuples in topological order
430
468
 
431
469
  Raises:
432
- ValueError: If self-reference cannot be resolved (missing name, etc.)
470
+ ValueError: If self-reference cannot be resolved (missing name, out of bounds index, etc.)
433
471
 
434
472
  Example:
435
- If task.cmd contains "{{ self.inputs.src }}" and task has input {src: "*.txt"},
436
- it will be resolved to "*.txt" (literal string, no glob expansion).
473
+ If task.cmd contains "{{ self.inputs.src }}" and task has input {src: "*.txt"},
474
+ it will be resolved to "*.txt" (literal string, no glob expansion).
475
+ If task.cmd contains "{{ self.inputs.0 }}" and task has inputs ["*.txt", "*.md"],
476
+ it will be resolved to "*.txt" (first input in YAML order).
477
+ @athena: 641e28567d1d
437
478
  """
438
479
  from tasktree.substitution import substitute_self_references
439
480
 
@@ -449,6 +490,8 @@ def resolve_self_references(
449
490
  task_name,
450
491
  task._input_map,
451
492
  task._output_map,
493
+ task._indexed_inputs,
494
+ task._indexed_outputs,
452
495
  )
453
496
 
454
497
  # Resolve self-references in working_dir
@@ -458,6 +501,8 @@ def resolve_self_references(
458
501
  task_name,
459
502
  task._input_map,
460
503
  task._output_map,
504
+ task._indexed_inputs,
505
+ task._indexed_outputs,
461
506
  )
462
507
 
463
508
  # Resolve self-references in argument defaults
@@ -472,26 +517,30 @@ def resolve_self_references(
472
517
  task_name,
473
518
  task._input_map,
474
519
  task._output_map,
520
+ task._indexed_inputs,
521
+ task._indexed_outputs,
475
522
  )
476
523
 
477
524
 
478
525
  def get_implicit_inputs(recipe: Recipe, task: Task) -> list[str]:
479
- """Get implicit inputs for a task based on its dependencies.
526
+ """
527
+ Get implicit inputs for a task based on its dependencies.
480
528
 
481
529
  Tasks automatically inherit inputs from dependencies:
482
530
  1. All outputs from dependency tasks become implicit inputs
483
531
  2. All inputs from dependency tasks that don't declare outputs are inherited
484
532
  3. If task uses a Docker environment, Docker artifacts become implicit inputs:
485
- - Dockerfile
486
- - .dockerignore (if present)
487
- - Special markers for context directory and base image digests
533
+ - Dockerfile
534
+ - .dockerignore (if present)
535
+ - Special markers for context directory and base image digests
488
536
 
489
537
  Args:
490
- recipe: Parsed recipe containing all tasks
491
- task: Task to get implicit inputs for
538
+ recipe: Parsed recipe containing all tasks
539
+ task: Task to get implicit inputs for
492
540
 
493
541
  Returns:
494
- List of glob patterns for implicit inputs, including Docker-specific markers
542
+ List of glob patterns for implicit inputs, including Docker-specific markers
543
+ @athena: da25e64fbd2b
495
544
  """
496
545
  implicit_inputs = []
497
546
 
@@ -544,19 +593,21 @@ def get_implicit_inputs(recipe: Recipe, task: Task) -> list[str]:
544
593
 
545
594
 
546
595
  def build_dependency_tree(recipe: Recipe, target_task: str, target_args: dict[str, Any] | None = None) -> dict:
547
- """Build a tree structure representing dependencies for visualization.
596
+ """
597
+ Build a tree structure representing dependencies for visualization.
548
598
 
549
599
  Note: This builds a true tree representation where shared dependencies may
550
600
  appear multiple times. Each dependency is shown in the context of its parent,
551
601
  allowing the full dependency path to be visible from any node.
552
602
 
553
603
  Args:
554
- recipe: Parsed recipe containing all tasks
555
- target_task: Name of the task to build tree for
556
- target_args: Arguments for the target task (optional)
604
+ recipe: Parsed recipe containing all tasks
605
+ target_task: Name of the task to build tree for
606
+ target_args: Arguments for the target task (optional)
557
607
 
558
608
  Returns:
559
- Nested dictionary representing the dependency tree
609
+ Nested dictionary representing the dependency tree
610
+ @athena: 570e5c663887
560
611
  """
561
612
  if target_task not in recipe.tasks:
562
613
  raise TaskNotFoundError(f"Task not found: {target_task}")
tasktree/hasher.py CHANGED
@@ -1,16 +1,25 @@
1
+ """Task and argument hashing for incremental execution.
2
+
3
+ Provides functions to compute deterministic hashes of task definitions,
4
+ arguments, and environment configurations. These hashes are used to detect
5
+ changes that require task re-execution in the incremental build system.
6
+ """
7
+
1
8
  import hashlib
2
9
  import json
3
10
  from typing import Any, Optional
4
11
 
5
12
 
6
13
  def _arg_sort_key(arg: str | dict[str, Any]) -> str:
7
- """Extract the sort key from an arg for deterministic hashing.
14
+ """
15
+ Extract the sort key from an arg for deterministic hashing.
8
16
 
9
17
  Args:
10
- arg: Either a string arg or dict arg specification
18
+ arg: Either a string arg or dict arg specification
11
19
 
12
20
  Returns:
13
- The argument name to use as a sort key
21
+ The argument name to use as a sort key
22
+ @athena: c88bb2cf696b
14
23
  """
15
24
  if isinstance(arg, dict):
16
25
  # Dict args have exactly one key - the argument name
@@ -20,6 +29,17 @@ def _arg_sort_key(arg: str | dict[str, Any]) -> str:
20
29
 
21
30
 
22
31
  def _normalize_choices_lists(args: list[str | dict[str, Any]]) -> list[str | dict[str, Any]]:
32
+ """
33
+ Normalize argument choices lists by sorting them for deterministic hashing.
34
+
35
+ Args:
36
+ args: List of argument specifications (strings or dicts)
37
+
38
+ Returns:
39
+ List of argument specs with sorted choices lists
40
+
41
+ @athena: 7512379275e3
42
+ """
23
43
  normalized_args = []
24
44
  for arg in args:
25
45
  if isinstance(arg, dict):
@@ -38,20 +58,22 @@ def _normalize_choices_lists(args: list[str | dict[str, Any]]) -> list[str | di
38
58
 
39
59
 
40
60
  def _serialize_outputs_for_hash(outputs: list[str | dict[str, str]]) -> list[str]:
41
- """Serialize outputs to consistent list of strings for hashing.
61
+ """
62
+ Serialize outputs to consistent list of strings for hashing.
42
63
 
43
64
  Converts both named outputs (dicts) and anonymous outputs (strings)
44
65
  into a consistent, sortable format.
45
66
 
46
67
  Args:
47
- outputs: List of output specifications (strings or dicts)
68
+ outputs: List of output specifications (strings or dicts)
48
69
 
49
70
  Returns:
50
- List of serialized output strings in sorted order
71
+ List of serialized output strings in sorted order
51
72
 
52
73
  Example:
53
- >>> _serialize_outputs_for_hash(["file.txt", {"bundle": "app.js"}])
54
- ['bundle:app.js', 'file.txt']
74
+ >>> _serialize_outputs_for_hash(["file.txt", {"bundle": "app.js"}])
75
+ ['bundle:app.js', 'file.txt']
76
+ @athena: 933995400691
55
77
  """
56
78
  serialized = []
57
79
  for output in outputs:
@@ -74,18 +96,20 @@ def hash_task(
74
96
  env: str = "",
75
97
  deps: list[str | dict[str, Any]] | None = None
76
98
  ) -> str:
77
- """Hash task definition including dependencies.
99
+ """
100
+ Hash task definition including dependencies.
78
101
 
79
102
  Args:
80
- cmd: Task command
81
- outputs: Task outputs (strings or named dicts)
82
- working_dir: Working directory
83
- args: Task argument specifications
84
- env: Environment name
85
- deps: Dependency specifications (optional, for dependency hash)
103
+ cmd: Task command
104
+ outputs: Task outputs (strings or named dicts)
105
+ working_dir: Working directory
106
+ args: Task argument specifications
107
+ env: Environment name
108
+ deps: Dependency specifications (optional, for dependency hash)
86
109
 
87
110
  Returns:
88
- 8-character hash of task definition
111
+ 8-character hash of task definition
112
+ @athena: 7a461d51a8bb
89
113
  """
90
114
  data = {
91
115
  "cmd": cmd,
@@ -117,18 +141,31 @@ def hash_task(
117
141
 
118
142
 
119
143
  def hash_args(args_dict: dict[str, Any]) -> str:
144
+ """
145
+ Hash task argument values for cache key generation.
146
+
147
+ Args:
148
+ args_dict: Dictionary mapping argument names to their values
149
+
150
+ Returns:
151
+ 8-character hash of the argument dictionary
152
+
153
+ @athena: 768e562bff64
154
+ """
120
155
  serialized = json.dumps(args_dict, sort_keys=True, separators=(",", ":"))
121
156
  return hashlib.sha256(serialized.encode()).hexdigest()[:8]
122
157
 
123
158
 
124
159
  def hash_environment_definition(env) -> str:
125
- """Hash environment definition fields that affect task execution.
160
+ """
161
+ Hash environment definition fields that affect task execution.
126
162
 
127
163
  Args:
128
- env: Environment to hash
164
+ env: Environment to hash
129
165
 
130
166
  Returns:
131
- 16-character hash of environment definition
167
+ 16-character hash of environment definition
168
+ @athena: 2de34f1a0b4a
132
169
  """
133
170
  # Import inside function to avoid circular dependency
134
171
  from tasktree.parser import Environment
@@ -156,6 +193,18 @@ def hash_environment_definition(env) -> str:
156
193
 
157
194
 
158
195
  def make_cache_key(task_hash: str, args_hash: Optional[str] = None) -> str:
196
+ """
197
+ Combine task and argument hashes into a cache key.
198
+
199
+ Args:
200
+ task_hash: Hash of the task definition
201
+ args_hash: Optional hash of task arguments
202
+
203
+ Returns:
204
+ Combined cache key string in format "task_hash__args_hash" or just "task_hash"
205
+
206
+ @athena: c85093f485c7
207
+ """
159
208
  if args_hash:
160
209
  return f"{task_hash}__{args_hash}"
161
210
  return task_hash