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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from tasktree.logging import Logger, LogLevel
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConsoleLogger(Logger):
|
|
6
|
+
"""
|
|
7
|
+
Console-based logger implementation using Rich for formatting.
|
|
8
|
+
|
|
9
|
+
Filters log messages based on the current log level. Messages with severity
|
|
10
|
+
lower than the current level are suppressed. Supports a stack-based level
|
|
11
|
+
management system for temporary verbosity changes.
|
|
12
|
+
@athena: a892913fc11c
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, console: Console, level: LogLevel = LogLevel.INFO) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Initialize the console logger.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
console: Rich Console instance to use for output
|
|
21
|
+
level: Initial log level (default: INFO)
|
|
22
|
+
@athena: 388d9c273a6a
|
|
23
|
+
"""
|
|
24
|
+
self._console = console
|
|
25
|
+
self._levels = [level]
|
|
26
|
+
|
|
27
|
+
def log(self, level: LogLevel = LogLevel.INFO, *args, **kwargs) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Log a message to the console if it meets the current level threshold.
|
|
30
|
+
|
|
31
|
+
Messages are only printed if their level is at or above the current level
|
|
32
|
+
(i.e., level.value <= current_level.value, since lower values = higher severity).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
level: The severity level of this message (default: INFO)
|
|
36
|
+
*args: Positional arguments passed to Rich Console.print()
|
|
37
|
+
**kwargs: Keyword arguments passed to Rich Console.print()
|
|
38
|
+
@athena: efae38733da1
|
|
39
|
+
"""
|
|
40
|
+
if self._levels[-1].value >= level.value:
|
|
41
|
+
self._console.print(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
def push_level(self, level: LogLevel) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Push a new log level onto the stack.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
level: The new log level to activate
|
|
49
|
+
@athena: 07c7493570b1
|
|
50
|
+
"""
|
|
51
|
+
self._levels.append(level)
|
|
52
|
+
|
|
53
|
+
def pop_level(self) -> LogLevel:
|
|
54
|
+
"""
|
|
55
|
+
Pop the current log level and return to the previous level.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The log level that was popped
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RuntimeError: If attempting to pop the base (initial) log level
|
|
62
|
+
@athena: 65ac19e00168
|
|
63
|
+
"""
|
|
64
|
+
if len(self._levels) <= 1:
|
|
65
|
+
raise RuntimeError("Cannot pop the base log level")
|
|
66
|
+
return self._levels.pop()
|
tasktree/docker.py
CHANGED
|
@@ -9,10 +9,11 @@ import os
|
|
|
9
9
|
import platform
|
|
10
10
|
import re
|
|
11
11
|
import subprocess
|
|
12
|
-
import time
|
|
13
12
|
from pathlib import Path
|
|
14
13
|
from typing import TYPE_CHECKING
|
|
15
14
|
|
|
15
|
+
from pathspec import PathSpec
|
|
16
|
+
|
|
16
17
|
try:
|
|
17
18
|
import pathspec
|
|
18
19
|
except ImportError:
|
|
@@ -20,6 +21,7 @@ except ImportError:
|
|
|
20
21
|
|
|
21
22
|
if TYPE_CHECKING:
|
|
22
23
|
from tasktree.parser import Environment
|
|
24
|
+
from tasktree.process_runner import ProcessRunner
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
class DockerError(Exception):
|
|
@@ -34,7 +36,7 @@ class DockerError(Exception):
|
|
|
34
36
|
class DockerManager:
|
|
35
37
|
"""
|
|
36
38
|
Manages Docker image building and container execution.
|
|
37
|
-
@athena:
|
|
39
|
+
@athena: f8f5c2693d84
|
|
38
40
|
"""
|
|
39
41
|
|
|
40
42
|
def __init__(self, project_root: Path):
|
|
@@ -46,9 +48,12 @@ class DockerManager:
|
|
|
46
48
|
@athena: eb7d4c5a27aa
|
|
47
49
|
"""
|
|
48
50
|
self._project_root = project_root
|
|
49
|
-
self._built_images: dict[
|
|
51
|
+
self._built_images: dict[
|
|
52
|
+
str, tuple[str, str]
|
|
53
|
+
] = {} # env_name -> (image_tag, image_id) cache
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _should_add_user_flag() -> bool:
|
|
52
57
|
"""
|
|
53
58
|
Check if --user flag should be added to docker run.
|
|
54
59
|
|
|
@@ -57,7 +62,7 @@ class DockerManager:
|
|
|
57
62
|
|
|
58
63
|
Returns:
|
|
59
64
|
True if --user flag should be added, False otherwise
|
|
60
|
-
@athena:
|
|
65
|
+
@athena: c5932076dfda
|
|
61
66
|
"""
|
|
62
67
|
# Skip on Windows - Docker Desktop handles UID mapping differently
|
|
63
68
|
if platform.system() == "Windows":
|
|
@@ -66,12 +71,15 @@ class DockerManager:
|
|
|
66
71
|
# Check if os.getuid() and os.getgid() are available (Linux/macOS)
|
|
67
72
|
return hasattr(os, "getuid") and hasattr(os, "getgid")
|
|
68
73
|
|
|
69
|
-
def ensure_image_built(
|
|
74
|
+
def ensure_image_built(
|
|
75
|
+
self, env: Environment, process_runner: ProcessRunner
|
|
76
|
+
) -> tuple[str, str]:
|
|
70
77
|
"""
|
|
71
78
|
Build Docker image if not already built this invocation.
|
|
72
79
|
|
|
73
80
|
Args:
|
|
74
81
|
env: Environment definition with dockerfile and context
|
|
82
|
+
process_runner: ProcessRunner instance for subprocess execution
|
|
75
83
|
|
|
76
84
|
Returns:
|
|
77
85
|
Tuple of (image_tag, image_id)
|
|
@@ -80,7 +88,7 @@ class DockerManager:
|
|
|
80
88
|
|
|
81
89
|
Raises:
|
|
82
90
|
DockerError: If docker command not available or build fails
|
|
83
|
-
@athena:
|
|
91
|
+
@athena: 42b53d2685e0
|
|
84
92
|
"""
|
|
85
93
|
# Check if already built this invocation
|
|
86
94
|
if env.name in self._built_images:
|
|
@@ -115,7 +123,7 @@ class DockerManager:
|
|
|
115
123
|
|
|
116
124
|
docker_build_cmd.append(str(context_path))
|
|
117
125
|
|
|
118
|
-
|
|
126
|
+
process_runner.run(
|
|
119
127
|
docker_build_cmd,
|
|
120
128
|
check=True,
|
|
121
129
|
capture_output=False, # Show build output to user
|
|
@@ -143,6 +151,7 @@ class DockerManager:
|
|
|
143
151
|
cmd: str,
|
|
144
152
|
working_dir: Path,
|
|
145
153
|
container_working_dir: str,
|
|
154
|
+
process_runner: ProcessRunner,
|
|
146
155
|
) -> subprocess.CompletedProcess:
|
|
147
156
|
"""
|
|
148
157
|
Execute command inside Docker container.
|
|
@@ -152,16 +161,17 @@ class DockerManager:
|
|
|
152
161
|
cmd: Command to execute
|
|
153
162
|
working_dir: Host working directory (for resolving relative volume paths)
|
|
154
163
|
container_working_dir: Working directory inside container
|
|
164
|
+
process_runner: ProcessRunner instance to use for subprocess execution
|
|
155
165
|
|
|
156
166
|
Returns:
|
|
157
167
|
CompletedProcess from subprocess.run
|
|
158
168
|
|
|
159
169
|
Raises:
|
|
160
170
|
DockerError: If docker run fails
|
|
161
|
-
@athena:
|
|
171
|
+
@athena: ef024ea2c182
|
|
162
172
|
"""
|
|
163
173
|
# Ensure image is built (returns tag and ID)
|
|
164
|
-
image_tag, image_id = self.ensure_image_built(env)
|
|
174
|
+
image_tag, image_id = self.ensure_image_built(env, process_runner)
|
|
165
175
|
|
|
166
176
|
# Build docker run command
|
|
167
177
|
docker_cmd = ["docker", "run", "--rm"]
|
|
@@ -197,11 +207,14 @@ class DockerManager:
|
|
|
197
207
|
|
|
198
208
|
# Add shell and command
|
|
199
209
|
shell = env.shell or "sh"
|
|
200
|
-
|
|
210
|
+
shell_args = (
|
|
211
|
+
env.args or [] if isinstance(env.args, list) else list(env.args.values())
|
|
212
|
+
)
|
|
213
|
+
docker_cmd.extend([shell, *shell_args, "-c", cmd])
|
|
201
214
|
|
|
202
215
|
# Execute
|
|
203
216
|
try:
|
|
204
|
-
result =
|
|
217
|
+
result = process_runner.run(
|
|
205
218
|
docker_cmd,
|
|
206
219
|
cwd=working_dir,
|
|
207
220
|
check=True,
|
|
@@ -250,13 +263,14 @@ class DockerManager:
|
|
|
250
263
|
|
|
251
264
|
return f"{resolved_host_path}:{container_path}"
|
|
252
265
|
|
|
253
|
-
|
|
266
|
+
@staticmethod
|
|
267
|
+
def _check_docker_available() -> None:
|
|
254
268
|
"""
|
|
255
269
|
Check if docker command is available.
|
|
256
270
|
|
|
257
271
|
Raises:
|
|
258
272
|
DockerError: If docker is not available
|
|
259
|
-
@athena:
|
|
273
|
+
@athena: 8deaf8c5c05e
|
|
260
274
|
"""
|
|
261
275
|
try:
|
|
262
276
|
subprocess.run(
|
|
@@ -271,7 +285,8 @@ class DockerManager:
|
|
|
271
285
|
"Visit https://docs.docker.com/get-docker/ for installation instructions."
|
|
272
286
|
)
|
|
273
287
|
|
|
274
|
-
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _get_image_id(image_tag: str) -> str:
|
|
275
290
|
"""
|
|
276
291
|
Get the full image ID for a given tag.
|
|
277
292
|
|
|
@@ -283,7 +298,7 @@ class DockerManager:
|
|
|
283
298
|
|
|
284
299
|
Raises:
|
|
285
300
|
DockerError: If cannot inspect image
|
|
286
|
-
@athena:
|
|
301
|
+
@athena: 9e5aa77003ee
|
|
287
302
|
"""
|
|
288
303
|
try:
|
|
289
304
|
result = subprocess.run(
|
|
@@ -312,9 +327,7 @@ def is_docker_environment(env: Environment) -> bool:
|
|
|
312
327
|
return bool(env.dockerfile)
|
|
313
328
|
|
|
314
329
|
|
|
315
|
-
def resolve_container_working_dir(
|
|
316
|
-
env_working_dir: str, task_working_dir: str
|
|
317
|
-
) -> str:
|
|
330
|
+
def resolve_container_working_dir(env_working_dir: str, task_working_dir: str) -> str:
|
|
318
331
|
"""
|
|
319
332
|
Resolve working directory inside container.
|
|
320
333
|
|
|
@@ -345,7 +358,7 @@ def resolve_container_working_dir(
|
|
|
345
358
|
return f"/{task_working_dir.lstrip('/')}"
|
|
346
359
|
|
|
347
360
|
|
|
348
|
-
def parse_dockerignore(dockerignore_path: Path) ->
|
|
361
|
+
def parse_dockerignore(dockerignore_path: Path) -> PathSpec | None:
|
|
349
362
|
"""
|
|
350
363
|
Parse .dockerignore file into pathspec matcher.
|
|
351
364
|
|
|
@@ -354,7 +367,7 @@ def parse_dockerignore(dockerignore_path: Path) -> "pathspec.PathSpec | None":
|
|
|
354
367
|
|
|
355
368
|
Returns:
|
|
356
369
|
PathSpec object for matching, or None if file doesn't exist or pathspec not available
|
|
357
|
-
@athena:
|
|
370
|
+
@athena: 13fca9ee5a73
|
|
358
371
|
"""
|
|
359
372
|
if pathspec is None:
|
|
360
373
|
# pathspec library not available - can't parse .dockerignore
|
|
@@ -432,13 +445,13 @@ def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]
|
|
|
432
445
|
Returns:
|
|
433
446
|
List of (image_reference, digest) tuples where digest may be None for unpinned images
|
|
434
447
|
Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
|
|
435
|
-
@athena:
|
|
448
|
+
@athena: de0d013fdd05
|
|
436
449
|
"""
|
|
437
450
|
# Regex pattern to match FROM lines
|
|
438
451
|
# Handles: FROM [--platform=...] image[:tag][@digest] [AS alias]
|
|
439
452
|
from_pattern = re.compile(
|
|
440
453
|
r"^\s*FROM\s+" # FROM keyword
|
|
441
|
-
r"(?:--platform
|
|
454
|
+
r"(?:--platform=\S+\s+)?" # Optional platform flag
|
|
442
455
|
r"([^\s@]+)" # Image name (possibly with :tag)
|
|
443
456
|
r"(?:@(sha256:[a-f0-9]+))?" # Optional @digest
|
|
444
457
|
r"(?:\s+AS\s+\w+)?" # Optional AS alias
|