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/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:
|
|
@@ -23,31 +24,44 @@ if TYPE_CHECKING:
|
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class DockerError(Exception):
|
|
26
|
-
"""
|
|
27
|
+
"""
|
|
28
|
+
Raised when Docker operations fail.
|
|
29
|
+
@athena: 876629e35765
|
|
30
|
+
"""
|
|
27
31
|
|
|
28
32
|
pass
|
|
29
33
|
|
|
30
34
|
|
|
31
35
|
class DockerManager:
|
|
32
|
-
"""
|
|
36
|
+
"""
|
|
37
|
+
Manages Docker image building and container execution.
|
|
38
|
+
@athena: 1a8a919eb05d
|
|
39
|
+
"""
|
|
33
40
|
|
|
34
41
|
def __init__(self, project_root: Path):
|
|
35
|
-
"""
|
|
42
|
+
"""
|
|
43
|
+
Initialize Docker manager.
|
|
36
44
|
|
|
37
45
|
Args:
|
|
38
|
-
|
|
46
|
+
project_root: Root directory of the project (where tasktree.yaml is located)
|
|
47
|
+
@athena: eb7d4c5a27aa
|
|
39
48
|
"""
|
|
40
49
|
self._project_root = project_root
|
|
41
|
-
self._built_images: dict[
|
|
50
|
+
self._built_images: dict[
|
|
51
|
+
str, tuple[str, str]
|
|
52
|
+
] = {} # env_name -> (image_tag, image_id) cache
|
|
42
53
|
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _should_add_user_flag() -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Check if --user flag should be added to docker run.
|
|
45
58
|
|
|
46
59
|
Returns False on Windows (where Docker Desktop handles UID mapping automatically).
|
|
47
60
|
Returns True on Linux/macOS where os.getuid() and os.getgid() are available.
|
|
48
61
|
|
|
49
62
|
Returns:
|
|
50
|
-
|
|
63
|
+
True if --user flag should be added, False otherwise
|
|
64
|
+
@athena: c5932076dfda
|
|
51
65
|
"""
|
|
52
66
|
# Skip on Windows - Docker Desktop handles UID mapping differently
|
|
53
67
|
if platform.system() == "Windows":
|
|
@@ -57,18 +71,20 @@ class DockerManager:
|
|
|
57
71
|
return hasattr(os, "getuid") and hasattr(os, "getgid")
|
|
58
72
|
|
|
59
73
|
def ensure_image_built(self, env: Environment) -> tuple[str, str]:
|
|
60
|
-
"""
|
|
74
|
+
"""
|
|
75
|
+
Build Docker image if not already built this invocation.
|
|
61
76
|
|
|
62
77
|
Args:
|
|
63
|
-
|
|
78
|
+
env: Environment definition with dockerfile and context
|
|
64
79
|
|
|
65
80
|
Returns:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
Tuple of (image_tag, image_id)
|
|
82
|
+
- image_tag: Tag like "tt-env-builder"
|
|
83
|
+
- image_id: Full image ID like "sha256:abc123..."
|
|
69
84
|
|
|
70
85
|
Raises:
|
|
71
|
-
|
|
86
|
+
DockerError: If docker command not available or build fails
|
|
87
|
+
@athena: 9b3c11c29fbb
|
|
72
88
|
"""
|
|
73
89
|
# Check if already built this invocation
|
|
74
90
|
if env.name in self._built_images:
|
|
@@ -132,19 +148,21 @@ class DockerManager:
|
|
|
132
148
|
working_dir: Path,
|
|
133
149
|
container_working_dir: str,
|
|
134
150
|
) -> subprocess.CompletedProcess:
|
|
135
|
-
"""
|
|
151
|
+
"""
|
|
152
|
+
Execute command inside Docker container.
|
|
136
153
|
|
|
137
154
|
Args:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
155
|
+
env: Environment definition
|
|
156
|
+
cmd: Command to execute
|
|
157
|
+
working_dir: Host working directory (for resolving relative volume paths)
|
|
158
|
+
container_working_dir: Working directory inside container
|
|
142
159
|
|
|
143
160
|
Returns:
|
|
144
|
-
|
|
161
|
+
CompletedProcess from subprocess.run
|
|
145
162
|
|
|
146
163
|
Raises:
|
|
147
|
-
|
|
164
|
+
DockerError: If docker run fails
|
|
165
|
+
@athena: f24fc9c27f81
|
|
148
166
|
"""
|
|
149
167
|
# Ensure image is built (returns tag and ID)
|
|
150
168
|
image_tag, image_id = self.ensure_image_built(env)
|
|
@@ -183,7 +201,10 @@ class DockerManager:
|
|
|
183
201
|
|
|
184
202
|
# Add shell and command
|
|
185
203
|
shell = env.shell or "sh"
|
|
186
|
-
|
|
204
|
+
shell_args = (
|
|
205
|
+
env.args or [] if isinstance(env.args, list) else list(env.args.values())
|
|
206
|
+
)
|
|
207
|
+
docker_cmd.extend([shell, *shell_args, "-c", cmd])
|
|
187
208
|
|
|
188
209
|
# Execute
|
|
189
210
|
try:
|
|
@@ -200,7 +221,8 @@ class DockerManager:
|
|
|
200
221
|
) from e
|
|
201
222
|
|
|
202
223
|
def _resolve_volume_mount(self, volume: str) -> str:
|
|
203
|
-
"""
|
|
224
|
+
"""
|
|
225
|
+
Resolve volume mount specification.
|
|
204
226
|
|
|
205
227
|
Handles:
|
|
206
228
|
- Relative paths (resolved relative to project_root)
|
|
@@ -208,10 +230,11 @@ class DockerManager:
|
|
|
208
230
|
- Absolute paths (used as-is)
|
|
209
231
|
|
|
210
232
|
Args:
|
|
211
|
-
|
|
233
|
+
volume: Volume specification (e.g., "./src:/workspace/src" or "~/.cargo:/root/.cargo")
|
|
212
234
|
|
|
213
235
|
Returns:
|
|
214
|
-
|
|
236
|
+
Resolved volume specification with absolute host path
|
|
237
|
+
@athena: c7661050443e
|
|
215
238
|
"""
|
|
216
239
|
if ":" not in volume:
|
|
217
240
|
raise ValueError(
|
|
@@ -234,11 +257,14 @@ class DockerManager:
|
|
|
234
257
|
|
|
235
258
|
return f"{resolved_host_path}:{container_path}"
|
|
236
259
|
|
|
237
|
-
|
|
238
|
-
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _check_docker_available() -> None:
|
|
262
|
+
"""
|
|
263
|
+
Check if docker command is available.
|
|
239
264
|
|
|
240
265
|
Raises:
|
|
241
|
-
|
|
266
|
+
DockerError: If docker is not available
|
|
267
|
+
@athena: 8deaf8c5c05e
|
|
242
268
|
"""
|
|
243
269
|
try:
|
|
244
270
|
subprocess.run(
|
|
@@ -253,17 +279,20 @@ class DockerManager:
|
|
|
253
279
|
"Visit https://docs.docker.com/get-docker/ for installation instructions."
|
|
254
280
|
)
|
|
255
281
|
|
|
256
|
-
|
|
257
|
-
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _get_image_id(image_tag: str) -> str:
|
|
284
|
+
"""
|
|
285
|
+
Get the full image ID for a given tag.
|
|
258
286
|
|
|
259
287
|
Args:
|
|
260
|
-
|
|
288
|
+
image_tag: Docker image tag (e.g., "tt-env-builder")
|
|
261
289
|
|
|
262
290
|
Returns:
|
|
263
|
-
|
|
291
|
+
Full image ID (e.g., "sha256:abc123def456...")
|
|
264
292
|
|
|
265
293
|
Raises:
|
|
266
|
-
|
|
294
|
+
DockerError: If cannot inspect image
|
|
295
|
+
@athena: 9e5aa77003ee
|
|
267
296
|
"""
|
|
268
297
|
try:
|
|
269
298
|
result = subprocess.run(
|
|
@@ -279,21 +308,22 @@ class DockerManager:
|
|
|
279
308
|
|
|
280
309
|
|
|
281
310
|
def is_docker_environment(env: Environment) -> bool:
|
|
282
|
-
"""
|
|
311
|
+
"""
|
|
312
|
+
Check if environment is Docker-based.
|
|
283
313
|
|
|
284
314
|
Args:
|
|
285
|
-
|
|
315
|
+
env: Environment to check
|
|
286
316
|
|
|
287
317
|
Returns:
|
|
288
|
-
|
|
318
|
+
True if environment has a dockerfile field, False otherwise
|
|
319
|
+
@athena: 1ffd255a4e90
|
|
289
320
|
"""
|
|
290
321
|
return bool(env.dockerfile)
|
|
291
322
|
|
|
292
323
|
|
|
293
|
-
def resolve_container_working_dir(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
"""Resolve working directory inside container.
|
|
324
|
+
def resolve_container_working_dir(env_working_dir: str, task_working_dir: str) -> str:
|
|
325
|
+
"""
|
|
326
|
+
Resolve working directory inside container.
|
|
297
327
|
|
|
298
328
|
Combines environment's working_dir with task's working_dir:
|
|
299
329
|
- If task specifies working_dir: container_dir = env_working_dir / task_working_dir
|
|
@@ -301,11 +331,12 @@ def resolve_container_working_dir(
|
|
|
301
331
|
- If neither specify: container_dir = "/" (Docker default)
|
|
302
332
|
|
|
303
333
|
Args:
|
|
304
|
-
|
|
305
|
-
|
|
334
|
+
env_working_dir: Working directory from environment definition
|
|
335
|
+
task_working_dir: Working directory from task definition
|
|
306
336
|
|
|
307
337
|
Returns:
|
|
308
|
-
|
|
338
|
+
Resolved working directory path
|
|
339
|
+
@athena: bb13d00dd07d
|
|
309
340
|
"""
|
|
310
341
|
if not env_working_dir and not task_working_dir:
|
|
311
342
|
return "/"
|
|
@@ -321,14 +352,16 @@ def resolve_container_working_dir(
|
|
|
321
352
|
return f"/{task_working_dir.lstrip('/')}"
|
|
322
353
|
|
|
323
354
|
|
|
324
|
-
def parse_dockerignore(dockerignore_path: Path) ->
|
|
325
|
-
"""
|
|
355
|
+
def parse_dockerignore(dockerignore_path: Path) -> PathSpec | None:
|
|
356
|
+
"""
|
|
357
|
+
Parse .dockerignore file into pathspec matcher.
|
|
326
358
|
|
|
327
359
|
Args:
|
|
328
|
-
|
|
360
|
+
dockerignore_path: Path to .dockerignore file
|
|
329
361
|
|
|
330
362
|
Returns:
|
|
331
|
-
|
|
363
|
+
PathSpec object for matching, or None if file doesn't exist or pathspec not available
|
|
364
|
+
@athena: 62bc07a3c6d0
|
|
332
365
|
"""
|
|
333
366
|
if pathspec is None:
|
|
334
367
|
# pathspec library not available - can't parse .dockerignore
|
|
@@ -351,17 +384,19 @@ def context_changed_since(
|
|
|
351
384
|
dockerignore_path: Path | None,
|
|
352
385
|
last_run_time: float,
|
|
353
386
|
) -> bool:
|
|
354
|
-
"""
|
|
387
|
+
"""
|
|
388
|
+
Check if any file in Docker build context has changed since last run.
|
|
355
389
|
|
|
356
390
|
Uses early-exit optimization: stops on first changed file found.
|
|
357
391
|
|
|
358
392
|
Args:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
393
|
+
context_path: Path to Docker build context directory
|
|
394
|
+
dockerignore_path: Optional path to .dockerignore file
|
|
395
|
+
last_run_time: Unix timestamp of last task run
|
|
362
396
|
|
|
363
397
|
Returns:
|
|
364
|
-
|
|
398
|
+
True if any file changed, False otherwise
|
|
399
|
+
@athena: 556acb1ed6ca
|
|
365
400
|
"""
|
|
366
401
|
# Parse .dockerignore
|
|
367
402
|
dockerignore_spec = None
|
|
@@ -395,20 +430,22 @@ def context_changed_since(
|
|
|
395
430
|
|
|
396
431
|
|
|
397
432
|
def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]:
|
|
398
|
-
"""
|
|
433
|
+
"""
|
|
434
|
+
Extract image references from FROM lines in Dockerfile.
|
|
399
435
|
|
|
400
436
|
Args:
|
|
401
|
-
|
|
437
|
+
dockerfile_content: Content of Dockerfile
|
|
402
438
|
|
|
403
439
|
Returns:
|
|
404
|
-
|
|
405
|
-
|
|
440
|
+
List of (image_reference, digest) tuples where digest may be None for unpinned images
|
|
441
|
+
Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
|
|
442
|
+
@athena: de0d013fdd05
|
|
406
443
|
"""
|
|
407
444
|
# Regex pattern to match FROM lines
|
|
408
445
|
# Handles: FROM [--platform=...] image[:tag][@digest] [AS alias]
|
|
409
446
|
from_pattern = re.compile(
|
|
410
447
|
r"^\s*FROM\s+" # FROM keyword
|
|
411
|
-
r"(?:--platform
|
|
448
|
+
r"(?:--platform=\S+\s+)?" # Optional platform flag
|
|
412
449
|
r"([^\s@]+)" # Image name (possibly with :tag)
|
|
413
450
|
r"(?:@(sha256:[a-f0-9]+))?" # Optional @digest
|
|
414
451
|
r"(?:\s+AS\s+\w+)?" # Optional AS alias
|
|
@@ -421,26 +458,30 @@ def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]
|
|
|
421
458
|
|
|
422
459
|
|
|
423
460
|
def check_unpinned_images(dockerfile_content: str) -> list[str]:
|
|
424
|
-
"""
|
|
461
|
+
"""
|
|
462
|
+
Check for unpinned base images in Dockerfile.
|
|
425
463
|
|
|
426
464
|
Args:
|
|
427
|
-
|
|
465
|
+
dockerfile_content: Content of Dockerfile
|
|
428
466
|
|
|
429
467
|
Returns:
|
|
430
|
-
|
|
468
|
+
List of unpinned image references (images without @sha256:... digests)
|
|
469
|
+
@athena: 58cc6de8fc96
|
|
431
470
|
"""
|
|
432
471
|
images = extract_from_images(dockerfile_content)
|
|
433
472
|
return [image for image, digest in images if digest is None]
|
|
434
473
|
|
|
435
474
|
|
|
436
475
|
def parse_base_image_digests(dockerfile_content: str) -> list[str]:
|
|
437
|
-
"""
|
|
476
|
+
"""
|
|
477
|
+
Parse pinned base image digests from Dockerfile.
|
|
438
478
|
|
|
439
479
|
Args:
|
|
440
|
-
|
|
480
|
+
dockerfile_content: Content of Dockerfile
|
|
441
481
|
|
|
442
482
|
Returns:
|
|
443
|
-
|
|
483
|
+
List of digests (e.g., ["sha256:abc123...", "sha256:def456..."])
|
|
484
|
+
@athena: c4d1da6b067c
|
|
444
485
|
"""
|
|
445
486
|
images = extract_from_images(dockerfile_content)
|
|
446
487
|
return [digest for _image, digest in images if digest is not None]
|