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
tasktree/graph.py
CHANGED
|
@@ -4,7 +4,6 @@ Dependency resolution using topological sorting.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from graphlib import TopologicalSorter
|
|
7
|
-
from pathlib import Path
|
|
8
7
|
from typing import Any
|
|
9
8
|
|
|
10
9
|
from tasktree.hasher import hash_args
|
|
@@ -13,7 +12,6 @@ from tasktree.parser import (
|
|
|
13
12
|
Task,
|
|
14
13
|
DependencyInvocation,
|
|
15
14
|
parse_dependency_spec,
|
|
16
|
-
parse_arg_spec,
|
|
17
15
|
)
|
|
18
16
|
from tasktree.substitution import substitute_dependency_args
|
|
19
17
|
|
|
@@ -58,7 +56,7 @@ def resolve_dependency_invocation(
|
|
|
58
56
|
parent_task_name: str,
|
|
59
57
|
parent_args: dict[str, Any],
|
|
60
58
|
parent_exported_args: set[str],
|
|
61
|
-
recipe: Recipe
|
|
59
|
+
recipe: Recipe,
|
|
62
60
|
) -> DependencyInvocation:
|
|
63
61
|
"""
|
|
64
62
|
Parse dependency specification and substitute parent argument templates.
|
|
@@ -100,7 +98,7 @@ def resolve_dependency_invocation(
|
|
|
100
98
|
... recipe
|
|
101
99
|
... )
|
|
102
100
|
DependencyInvocation("build", {"mode": "production"})
|
|
103
|
-
@athena:
|
|
101
|
+
@athena: dd9a84821159
|
|
104
102
|
"""
|
|
105
103
|
# Simple string case - no args to substitute
|
|
106
104
|
if isinstance(dep_spec, str):
|
|
@@ -246,9 +244,7 @@ class TaskNode:
|
|
|
246
244
|
|
|
247
245
|
|
|
248
246
|
def resolve_execution_order(
|
|
249
|
-
recipe: Recipe,
|
|
250
|
-
target_task: str,
|
|
251
|
-
target_args: dict[str, Any] | None = None
|
|
247
|
+
recipe: Recipe, target_task: str, target_args: dict[str, Any] | None = None
|
|
252
248
|
) -> list[tuple[str, dict[str, Any]]]:
|
|
253
249
|
"""
|
|
254
250
|
Resolve execution order for a task and its dependencies.
|
|
@@ -264,7 +260,7 @@ def resolve_execution_order(
|
|
|
264
260
|
Raises:
|
|
265
261
|
TaskNotFoundError: If target task or any dependency doesn't exist
|
|
266
262
|
CycleError: If a dependency cycle is detected
|
|
267
|
-
@athena:
|
|
263
|
+
@athena: 687f627efc75
|
|
268
264
|
"""
|
|
269
265
|
if target_task not in recipe.tasks:
|
|
270
266
|
raise TaskNotFoundError(f"Task not found: {target_task}")
|
|
@@ -273,7 +269,9 @@ def resolve_execution_order(
|
|
|
273
269
|
graph: dict[TaskNode, set[TaskNode]] = {}
|
|
274
270
|
|
|
275
271
|
# Track seen nodes to detect duplicates
|
|
276
|
-
seen_invocations: dict[
|
|
272
|
+
seen_invocations: dict[
|
|
273
|
+
tuple[str, str], TaskNode
|
|
274
|
+
] = {} # (task_name, args_hash) -> node
|
|
277
275
|
|
|
278
276
|
def get_or_create_node(task_name: str, args: dict[str, Any] | None) -> TaskNode:
|
|
279
277
|
"""Get existing node or create new one for this invocation."""
|
|
@@ -307,7 +305,7 @@ def resolve_execution_order(
|
|
|
307
305
|
parent_task_name=node.task_name,
|
|
308
306
|
parent_args=node.args or {},
|
|
309
307
|
parent_exported_args=parent_exported_args,
|
|
310
|
-
recipe=recipe
|
|
308
|
+
recipe=recipe,
|
|
311
309
|
)
|
|
312
310
|
|
|
313
311
|
# Create or get node for this dependency invocation
|
|
@@ -592,7 +590,9 @@ def get_implicit_inputs(recipe: Recipe, task: Task) -> list[str]:
|
|
|
592
590
|
return implicit_inputs
|
|
593
591
|
|
|
594
592
|
|
|
595
|
-
def build_dependency_tree(
|
|
593
|
+
def build_dependency_tree(
|
|
594
|
+
recipe: Recipe, target_task: str, target_args: dict[str, Any] | None = None
|
|
595
|
+
) -> dict:
|
|
596
596
|
"""
|
|
597
597
|
Build a tree structure representing dependencies for visualization.
|
|
598
598
|
|
|
@@ -607,7 +607,7 @@ def build_dependency_tree(recipe: Recipe, target_task: str, target_args: dict[st
|
|
|
607
607
|
|
|
608
608
|
Returns:
|
|
609
609
|
Nested dictionary representing the dependency tree
|
|
610
|
-
@athena:
|
|
610
|
+
@athena: 8c853b393bdb
|
|
611
611
|
"""
|
|
612
612
|
if target_task not in recipe.tasks:
|
|
613
613
|
raise TaskNotFoundError(f"Task not found: {target_task}")
|
|
@@ -622,12 +622,17 @@ def build_dependency_tree(recipe: Recipe, target_task: str, target_args: dict[st
|
|
|
622
622
|
|
|
623
623
|
# Create node identifier for cycle detection
|
|
624
624
|
from tasktree.hasher import hash_args
|
|
625
|
+
|
|
625
626
|
args_dict = args or {}
|
|
626
627
|
node_id = (task_name, hash_args(args_dict) if args_dict else "")
|
|
627
628
|
|
|
628
629
|
# Detect cycles in current recursion path
|
|
629
630
|
if node_id in current_path:
|
|
630
|
-
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
|
+
)
|
|
631
636
|
return {"name": display_name, "deps": [], "cycle": True}
|
|
632
637
|
|
|
633
638
|
current_path.add(node_id)
|
tasktree/hasher.py
CHANGED
|
@@ -28,17 +28,19 @@ def _arg_sort_key(arg: str | dict[str, Any]) -> str:
|
|
|
28
28
|
return arg
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def _normalize_choices_lists(
|
|
31
|
+
def _normalize_choices_lists(
|
|
32
|
+
args: list[str | dict[str, Any]],
|
|
33
|
+
) -> list[str | dict[str, Any]]:
|
|
32
34
|
"""
|
|
33
35
|
Normalize argument choices lists by sorting them for deterministic hashing.
|
|
34
36
|
|
|
35
37
|
Args:
|
|
36
|
-
|
|
38
|
+
args: List of argument specifications (strings or dicts)
|
|
37
39
|
|
|
38
40
|
Returns:
|
|
39
|
-
|
|
41
|
+
List of argument specs with sorted choices lists
|
|
40
42
|
|
|
41
|
-
@athena:
|
|
43
|
+
@athena: dc34b6fb09a6
|
|
42
44
|
"""
|
|
43
45
|
normalized_args = []
|
|
44
46
|
for arg in args:
|
|
@@ -46,8 +48,11 @@ def _normalize_choices_lists(args: list[str | dict[str, Any]]) -> list[str | di
|
|
|
46
48
|
# Deep copy and sort choices if present
|
|
47
49
|
normalized = {}
|
|
48
50
|
for key, value in arg.items():
|
|
49
|
-
if isinstance(value, dict) and
|
|
50
|
-
normalized[key] = {
|
|
51
|
+
if isinstance(value, dict) and "choices" in value:
|
|
52
|
+
normalized[key] = {
|
|
53
|
+
**value,
|
|
54
|
+
"choices": sorted(value["choices"], key=str),
|
|
55
|
+
}
|
|
51
56
|
else:
|
|
52
57
|
normalized[key] = value
|
|
53
58
|
normalized_args.append(normalized)
|
|
@@ -94,7 +99,7 @@ def hash_task(
|
|
|
94
99
|
working_dir: str,
|
|
95
100
|
args: list[str | dict[str, Any]],
|
|
96
101
|
env: str = "",
|
|
97
|
-
deps: list[str | dict[str, Any]] | None = None
|
|
102
|
+
deps: list[str | dict[str, Any]] | None = None,
|
|
98
103
|
) -> str:
|
|
99
104
|
"""
|
|
100
105
|
Hash task definition including dependencies.
|
|
@@ -109,7 +114,7 @@ def hash_task(
|
|
|
109
114
|
|
|
110
115
|
Returns:
|
|
111
116
|
8-character hash of task definition
|
|
112
|
-
@athena:
|
|
117
|
+
@athena: 815a47cf1af0
|
|
113
118
|
"""
|
|
114
119
|
data = {
|
|
115
120
|
"cmd": cmd,
|
|
@@ -134,7 +139,10 @@ def hash_task(
|
|
|
134
139
|
else:
|
|
135
140
|
normalized_deps.append(dep)
|
|
136
141
|
# Sort using JSON serialization for consistent ordering
|
|
137
|
-
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
|
+
)
|
|
138
146
|
|
|
139
147
|
serialized = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
140
148
|
return hashlib.sha256(serialized.encode()).hexdigest()[:8]
|
|
@@ -165,10 +173,9 @@ def hash_environment_definition(env) -> str:
|
|
|
165
173
|
|
|
166
174
|
Returns:
|
|
167
175
|
16-character hash of environment definition
|
|
168
|
-
@athena:
|
|
176
|
+
@athena: a54f6c171ba9
|
|
169
177
|
"""
|
|
170
178
|
# Import inside function to avoid circular dependency
|
|
171
|
-
from tasktree.parser import Environment
|
|
172
179
|
|
|
173
180
|
# Handle args - can be list (shell args) or dict (docker build args)
|
|
174
181
|
args_value = env.args
|
tasktree/logging.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Logging infrastructure for Task Tree.
|
|
2
|
+
|
|
3
|
+
Provides a Logger interface for dependency injection of logging functionality.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
from abc import abstractmethod
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LogLevel(enum.Enum):
|
|
13
|
+
"""
|
|
14
|
+
Log verbosity levels for tasktree diagnostic messages.
|
|
15
|
+
|
|
16
|
+
Lower numeric values represent higher severity / less verbosity.
|
|
17
|
+
@athena: 3b2d8a378440
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
FATAL = 0 # Only unrecoverable errors (malformed task files, missing dependencies)
|
|
21
|
+
ERROR = 1 # Fatal errors plus task execution failures
|
|
22
|
+
WARN = 2 # Errors plus warnings about deprecated features, configuration issues
|
|
23
|
+
INFO = 3 # Warnings plus normal execution progress (default)
|
|
24
|
+
DEBUG = 4 # Info plus variable values, resolved paths, environment details
|
|
25
|
+
TRACE = 5 # Debug plus fine-grained execution tracing
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Logger:
|
|
29
|
+
"""
|
|
30
|
+
Abstract base class for logging implementations.
|
|
31
|
+
|
|
32
|
+
Provides a level-based logging interface with stack-based level management.
|
|
33
|
+
Concrete implementations must define how messages are output (e.g., to console, file, etc.).
|
|
34
|
+
@athena: fdcd08796011
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def log(self, level: LogLevel, *args, **kwargs) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Log a message at the specified level.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
level: The severity level of the message
|
|
44
|
+
*args: Positional arguments to log (strings, Rich objects, etc.)
|
|
45
|
+
**kwargs: Keyword arguments for formatting (e.g., style, justify)
|
|
46
|
+
@athena: 4563ae920ff4
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def push_level(self, level: LogLevel) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Push a new log level onto the level stack.
|
|
54
|
+
|
|
55
|
+
Messages below this level will be filtered out until pop_level() is called.
|
|
56
|
+
Useful for temporarily increasing verbosity in specific code sections.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
level: The new log level to use
|
|
60
|
+
@athena: c73ac375d30a
|
|
61
|
+
"""
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def pop_level(self) -> LogLevel:
|
|
66
|
+
"""
|
|
67
|
+
Pop the current log level from the stack and return to the previous level.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The log level that was popped
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
RuntimeError: If attempting to pop the base (initial) log level
|
|
74
|
+
@athena: d4721a34516f
|
|
75
|
+
"""
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def fatal(self, *args, **kwargs):
|
|
79
|
+
"""
|
|
80
|
+
@athena: 951c9c5d4f3b
|
|
81
|
+
"""
|
|
82
|
+
self.log(LogLevel.FATAL, *args, **kwargs)
|
|
83
|
+
|
|
84
|
+
def error(self, *args, **kwargs):
|
|
85
|
+
"""
|
|
86
|
+
@athena: 7125264a8ce1
|
|
87
|
+
"""
|
|
88
|
+
self.log(LogLevel.ERROR, *args, **kwargs)
|
|
89
|
+
|
|
90
|
+
def warn(self, *args, **kwargs):
|
|
91
|
+
"""
|
|
92
|
+
@athena: 5a70af1bb5b4
|
|
93
|
+
"""
|
|
94
|
+
self.log(LogLevel.WARN, *args, **kwargs)
|
|
95
|
+
|
|
96
|
+
def info(self, *args, **kwargs):
|
|
97
|
+
"""
|
|
98
|
+
@athena: cdb5381023a6
|
|
99
|
+
"""
|
|
100
|
+
self.log(LogLevel.INFO, *args, **kwargs)
|
|
101
|
+
|
|
102
|
+
def debug(self, *args, **kwargs):
|
|
103
|
+
"""
|
|
104
|
+
@athena: ddfd0e359f09
|
|
105
|
+
"""
|
|
106
|
+
self.log(LogLevel.DEBUG, *args, **kwargs)
|
|
107
|
+
|
|
108
|
+
def trace(self, *args, **kwargs):
|
|
109
|
+
"""
|
|
110
|
+
@athena: 4f615a15562c
|
|
111
|
+
"""
|
|
112
|
+
self.log(LogLevel.TRACE, *args, **kwargs)
|