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