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/__init__.py +4 -1
- tasktree/cli.py +107 -29
- tasktree/docker.py +87 -53
- tasktree/executor.py +194 -129
- tasktree/graph.py +123 -72
- tasktree/hasher.py +68 -19
- tasktree/parser.py +340 -229
- tasktree/state.py +46 -17
- tasktree/substitution.py +162 -103
- tasktree/types.py +50 -12
- {tasktree-0.0.20.dist-info → tasktree-0.0.21.dist-info}/METADATA +135 -7
- tasktree-0.0.21.dist-info/RECORD +14 -0
- tasktree-0.0.20.dist-info/RECORD +0 -14
- {tasktree-0.0.20.dist-info → tasktree-0.0.21.dist-info}/WHEEL +0 -0
- {tasktree-0.0.20.dist-info → tasktree-0.0.21.dist-info}/entry_points.txt +0 -0
tasktree/__init__.py
CHANGED
tasktree/cli.py
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
"""Command-line interface for Task Tree.
|
|
2
|
+
|
|
3
|
+
Provides a Typer-based CLI with commands for listing, showing, executing,
|
|
4
|
+
and managing task definitions. Supports task execution with incremental builds,
|
|
5
|
+
dependency resolution, and rich terminal output via the Rich library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
1
8
|
from __future__ import annotations
|
|
2
9
|
|
|
3
10
|
import os
|
|
@@ -29,10 +36,12 @@ console = Console()
|
|
|
29
36
|
|
|
30
37
|
|
|
31
38
|
def _supports_unicode() -> bool:
|
|
32
|
-
"""
|
|
39
|
+
"""
|
|
40
|
+
Check if the terminal supports Unicode characters.
|
|
33
41
|
|
|
34
42
|
Returns:
|
|
35
|
-
|
|
43
|
+
True if terminal supports UTF-8, False otherwise
|
|
44
|
+
@athena: 68f62a942a95
|
|
36
45
|
"""
|
|
37
46
|
# Hard stop: classic Windows console (conhost)
|
|
38
47
|
if os.name == "nt" and "WT_SESSION" not in os.environ:
|
|
@@ -51,37 +60,43 @@ def _supports_unicode() -> bool:
|
|
|
51
60
|
|
|
52
61
|
|
|
53
62
|
def get_action_success_string() -> str:
|
|
54
|
-
"""
|
|
63
|
+
"""
|
|
64
|
+
Get the appropriate success symbol based on terminal capabilities.
|
|
55
65
|
|
|
56
66
|
Returns:
|
|
57
|
-
|
|
67
|
+
Unicode tick symbol (✓) if terminal supports UTF-8, otherwise "[ OK ]"
|
|
68
|
+
@athena: 39d9966ee6c8
|
|
58
69
|
"""
|
|
59
70
|
return "✓" if _supports_unicode() else "[ OK ]"
|
|
60
71
|
|
|
61
72
|
|
|
62
73
|
def get_action_failure_string() -> str:
|
|
63
|
-
"""
|
|
74
|
+
"""
|
|
75
|
+
Get the appropriate failure symbol based on terminal capabilities.
|
|
64
76
|
|
|
65
77
|
Returns:
|
|
66
|
-
|
|
78
|
+
Unicode cross symbol (✗) if terminal supports UTF-8, otherwise "[ FAIL ]"
|
|
79
|
+
@athena: 5dd1111f8d74
|
|
67
80
|
"""
|
|
68
81
|
return "✗" if _supports_unicode() else "[ FAIL ]"
|
|
69
82
|
|
|
70
83
|
|
|
71
84
|
def _format_task_arguments(arg_specs: list[str | dict]) -> str:
|
|
72
|
-
"""
|
|
85
|
+
"""
|
|
86
|
+
Format task arguments for display in list output.
|
|
73
87
|
|
|
74
88
|
Args:
|
|
75
|
-
|
|
89
|
+
arg_specs: List of argument specifications from task definition (strings or dicts)
|
|
76
90
|
|
|
77
91
|
Returns:
|
|
78
|
-
|
|
92
|
+
Formatted string showing arguments with types and defaults
|
|
79
93
|
|
|
80
94
|
Examples:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
["mode", "target"] -> "mode:str target:str"
|
|
96
|
+
["mode=debug", "target=x86_64"] -> "mode:str [=debug] target:str [=x86_64]"
|
|
97
|
+
["port:int", "debug:bool=false"] -> "port:int debug:bool [=false]"
|
|
98
|
+
[{"timeout": {"type": "int", "default": 30}}] -> "timeout:int [=30]"
|
|
99
|
+
@athena: fc3d6da90aeb
|
|
85
100
|
"""
|
|
86
101
|
if not arg_specs:
|
|
87
102
|
return ""
|
|
@@ -104,7 +119,10 @@ def _format_task_arguments(arg_specs: list[str | dict]) -> str:
|
|
|
104
119
|
|
|
105
120
|
|
|
106
121
|
def _list_tasks(tasks_file: Optional[str] = None):
|
|
107
|
-
"""
|
|
122
|
+
"""
|
|
123
|
+
List all available tasks with descriptions.
|
|
124
|
+
@athena: 778f231737a1
|
|
125
|
+
"""
|
|
108
126
|
recipe = _get_recipe(tasks_file)
|
|
109
127
|
if recipe is None:
|
|
110
128
|
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
@@ -144,7 +162,10 @@ def _list_tasks(tasks_file: Optional[str] = None):
|
|
|
144
162
|
|
|
145
163
|
|
|
146
164
|
def _show_task(task_name: str, tasks_file: Optional[str] = None):
|
|
147
|
-
"""
|
|
165
|
+
"""
|
|
166
|
+
Show task definition with syntax highlighting.
|
|
167
|
+
@athena: 79ae3e330662
|
|
168
|
+
"""
|
|
148
169
|
# Pass task_name as root_task for lazy variable evaluation
|
|
149
170
|
recipe = _get_recipe(tasks_file, root_task=task_name)
|
|
150
171
|
if recipe is None:
|
|
@@ -194,7 +215,10 @@ def _show_task(task_name: str, tasks_file: Optional[str] = None):
|
|
|
194
215
|
|
|
195
216
|
|
|
196
217
|
def _show_tree(task_name: str, tasks_file: Optional[str] = None):
|
|
197
|
-
"""
|
|
218
|
+
"""
|
|
219
|
+
Show dependency tree structure.
|
|
220
|
+
@athena: a906cef99324
|
|
221
|
+
"""
|
|
198
222
|
# Pass task_name as root_task for lazy variable evaluation
|
|
199
223
|
recipe = _get_recipe(tasks_file, root_task=task_name)
|
|
200
224
|
if recipe is None:
|
|
@@ -219,7 +243,10 @@ def _show_tree(task_name: str, tasks_file: Optional[str] = None):
|
|
|
219
243
|
|
|
220
244
|
|
|
221
245
|
def _init_recipe():
|
|
222
|
-
"""
|
|
246
|
+
"""
|
|
247
|
+
Create a blank recipe file with commented examples.
|
|
248
|
+
@athena: 189726c9b6c0
|
|
249
|
+
"""
|
|
223
250
|
recipe_path = Path("tasktree.yaml")
|
|
224
251
|
if recipe_path.exists():
|
|
225
252
|
console.print("[red]tasktree.yaml already exists[/red]")
|
|
@@ -260,7 +287,10 @@ tasks:
|
|
|
260
287
|
|
|
261
288
|
|
|
262
289
|
def _version_callback(value: bool):
|
|
263
|
-
"""
|
|
290
|
+
"""
|
|
291
|
+
Show version and exit.
|
|
292
|
+
@athena: abaed96ac23b
|
|
293
|
+
"""
|
|
264
294
|
if value:
|
|
265
295
|
console.print(f"task-tree version {__version__}")
|
|
266
296
|
raise typer.Exit()
|
|
@@ -306,17 +336,19 @@ def main(
|
|
|
306
336
|
None, help="Task name and arguments"
|
|
307
337
|
),
|
|
308
338
|
):
|
|
309
|
-
"""
|
|
339
|
+
"""
|
|
340
|
+
Task Tree - A task automation tool with incremental execution.
|
|
310
341
|
|
|
311
342
|
Run tasks defined in tasktree.yaml with dependency tracking
|
|
312
343
|
and incremental execution.
|
|
313
344
|
|
|
314
345
|
Examples:
|
|
315
346
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
347
|
+
tt build # Run the 'build' task
|
|
348
|
+
tt deploy prod region=us-1 # Run 'deploy' with arguments
|
|
349
|
+
tt --list # List all tasks
|
|
350
|
+
tt --tree test # Show dependency tree for 'test'
|
|
351
|
+
@athena: f76c75c12d10
|
|
320
352
|
"""
|
|
321
353
|
|
|
322
354
|
if list_opt:
|
|
@@ -360,7 +392,10 @@ def main(
|
|
|
360
392
|
|
|
361
393
|
|
|
362
394
|
def _clean_state(tasks_file: Optional[str] = None) -> None:
|
|
363
|
-
"""
|
|
395
|
+
"""
|
|
396
|
+
Remove the .tasktree-state file to reset task execution state.
|
|
397
|
+
@athena: a0ddf4b333d4
|
|
398
|
+
"""
|
|
364
399
|
if tasks_file:
|
|
365
400
|
recipe_path = Path(tasks_file)
|
|
366
401
|
if not recipe_path.exists():
|
|
@@ -385,12 +420,14 @@ def _clean_state(tasks_file: Optional[str] = None) -> None:
|
|
|
385
420
|
|
|
386
421
|
|
|
387
422
|
def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = None) -> Optional[Recipe]:
|
|
388
|
-
"""
|
|
423
|
+
"""
|
|
424
|
+
Get parsed recipe or None if not found.
|
|
389
425
|
|
|
390
426
|
Args:
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
427
|
+
recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
|
|
428
|
+
root_task: Optional root task for lazy variable evaluation. If provided, only variables
|
|
429
|
+
reachable from this task will be evaluated (performance optimization).
|
|
430
|
+
@athena: 0ee00c67df25
|
|
394
431
|
"""
|
|
395
432
|
if recipe_file:
|
|
396
433
|
recipe_path = Path(recipe_file)
|
|
@@ -419,6 +456,18 @@ def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = No
|
|
|
419
456
|
|
|
420
457
|
|
|
421
458
|
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None, tasks_file: Optional[str] = None) -> None:
|
|
459
|
+
"""
|
|
460
|
+
Execute a task with its dependencies and handle argument parsing.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
args: Task name followed by optional task arguments
|
|
464
|
+
force: Force re-execution even if task is up-to-date
|
|
465
|
+
only: Execute only the specified task, skip dependencies
|
|
466
|
+
env: Override environment for task execution
|
|
467
|
+
tasks_file: Path to recipe file (optional)
|
|
468
|
+
|
|
469
|
+
@athena: 207f7635a60d
|
|
470
|
+
"""
|
|
422
471
|
if not args:
|
|
423
472
|
return
|
|
424
473
|
|
|
@@ -511,6 +560,21 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
|
|
|
511
560
|
|
|
512
561
|
|
|
513
562
|
def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, Any]:
|
|
563
|
+
"""
|
|
564
|
+
Parse and validate task arguments from command line values.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
arg_specs: Task argument specifications with types and defaults
|
|
568
|
+
arg_values: Raw argument values from command line (positional or named)
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
Dictionary mapping argument names to typed, validated values
|
|
572
|
+
|
|
573
|
+
Raises:
|
|
574
|
+
typer.Exit: If arguments are invalid, missing, or unknown
|
|
575
|
+
|
|
576
|
+
@athena: 2072a35f9d11
|
|
577
|
+
"""
|
|
514
578
|
if not arg_specs:
|
|
515
579
|
if arg_values:
|
|
516
580
|
console.print(f"[red]Task does not accept arguments[/red]")
|
|
@@ -579,6 +643,17 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
579
643
|
|
|
580
644
|
|
|
581
645
|
def _build_rich_tree(dep_tree: dict) -> Tree:
|
|
646
|
+
"""
|
|
647
|
+
Build a Rich Tree visualization from a dependency tree structure.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
dep_tree: Nested dictionary representing task dependencies
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
Rich Tree object for terminal display
|
|
654
|
+
|
|
655
|
+
@athena: 62472c8ca729
|
|
656
|
+
"""
|
|
582
657
|
task_name = dep_tree["name"]
|
|
583
658
|
tree = Tree(task_name)
|
|
584
659
|
|
|
@@ -591,7 +666,10 @@ def _build_rich_tree(dep_tree: dict) -> Tree:
|
|
|
591
666
|
|
|
592
667
|
|
|
593
668
|
def cli():
|
|
594
|
-
"""
|
|
669
|
+
"""
|
|
670
|
+
Entry point for the CLI.
|
|
671
|
+
@athena: 3b3cccd1ff6f
|
|
672
|
+
"""
|
|
595
673
|
app()
|
|
596
674
|
|
|
597
675
|
|
tasktree/docker.py
CHANGED
|
@@ -23,31 +23,41 @@ if TYPE_CHECKING:
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class DockerError(Exception):
|
|
26
|
-
"""
|
|
26
|
+
"""
|
|
27
|
+
Raised when Docker operations fail.
|
|
28
|
+
@athena: 876629e35765
|
|
29
|
+
"""
|
|
27
30
|
|
|
28
31
|
pass
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
class DockerManager:
|
|
32
|
-
"""
|
|
35
|
+
"""
|
|
36
|
+
Manages Docker image building and container execution.
|
|
37
|
+
@athena: ef3cc3d7bcbe
|
|
38
|
+
"""
|
|
33
39
|
|
|
34
40
|
def __init__(self, project_root: Path):
|
|
35
|
-
"""
|
|
41
|
+
"""
|
|
42
|
+
Initialize Docker manager.
|
|
36
43
|
|
|
37
44
|
Args:
|
|
38
|
-
|
|
45
|
+
project_root: Root directory of the project (where tasktree.yaml is located)
|
|
46
|
+
@athena: eb7d4c5a27aa
|
|
39
47
|
"""
|
|
40
48
|
self._project_root = project_root
|
|
41
49
|
self._built_images: dict[str, tuple[str, str]] = {} # env_name -> (image_tag, image_id) cache
|
|
42
50
|
|
|
43
51
|
def _should_add_user_flag(self) -> bool:
|
|
44
|
-
"""
|
|
52
|
+
"""
|
|
53
|
+
Check if --user flag should be added to docker run.
|
|
45
54
|
|
|
46
55
|
Returns False on Windows (where Docker Desktop handles UID mapping automatically).
|
|
47
56
|
Returns True on Linux/macOS where os.getuid() and os.getgid() are available.
|
|
48
57
|
|
|
49
58
|
Returns:
|
|
50
|
-
|
|
59
|
+
True if --user flag should be added, False otherwise
|
|
60
|
+
@athena: 6a872eea6a10
|
|
51
61
|
"""
|
|
52
62
|
# Skip on Windows - Docker Desktop handles UID mapping differently
|
|
53
63
|
if platform.system() == "Windows":
|
|
@@ -57,18 +67,20 @@ class DockerManager:
|
|
|
57
67
|
return hasattr(os, "getuid") and hasattr(os, "getgid")
|
|
58
68
|
|
|
59
69
|
def ensure_image_built(self, env: Environment) -> tuple[str, str]:
|
|
60
|
-
"""
|
|
70
|
+
"""
|
|
71
|
+
Build Docker image if not already built this invocation.
|
|
61
72
|
|
|
62
73
|
Args:
|
|
63
|
-
|
|
74
|
+
env: Environment definition with dockerfile and context
|
|
64
75
|
|
|
65
76
|
Returns:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
Tuple of (image_tag, image_id)
|
|
78
|
+
- image_tag: Tag like "tt-env-builder"
|
|
79
|
+
- image_id: Full image ID like "sha256:abc123..."
|
|
69
80
|
|
|
70
81
|
Raises:
|
|
71
|
-
|
|
82
|
+
DockerError: If docker command not available or build fails
|
|
83
|
+
@athena: 9b3c11c29fbb
|
|
72
84
|
"""
|
|
73
85
|
# Check if already built this invocation
|
|
74
86
|
if env.name in self._built_images:
|
|
@@ -132,19 +144,21 @@ class DockerManager:
|
|
|
132
144
|
working_dir: Path,
|
|
133
145
|
container_working_dir: str,
|
|
134
146
|
) -> subprocess.CompletedProcess:
|
|
135
|
-
"""
|
|
147
|
+
"""
|
|
148
|
+
Execute command inside Docker container.
|
|
136
149
|
|
|
137
150
|
Args:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
151
|
+
env: Environment definition
|
|
152
|
+
cmd: Command to execute
|
|
153
|
+
working_dir: Host working directory (for resolving relative volume paths)
|
|
154
|
+
container_working_dir: Working directory inside container
|
|
142
155
|
|
|
143
156
|
Returns:
|
|
144
|
-
|
|
157
|
+
CompletedProcess from subprocess.run
|
|
145
158
|
|
|
146
159
|
Raises:
|
|
147
|
-
|
|
160
|
+
DockerError: If docker run fails
|
|
161
|
+
@athena: 2c963babb5ca
|
|
148
162
|
"""
|
|
149
163
|
# Ensure image is built (returns tag and ID)
|
|
150
164
|
image_tag, image_id = self.ensure_image_built(env)
|
|
@@ -200,7 +214,8 @@ class DockerManager:
|
|
|
200
214
|
) from e
|
|
201
215
|
|
|
202
216
|
def _resolve_volume_mount(self, volume: str) -> str:
|
|
203
|
-
"""
|
|
217
|
+
"""
|
|
218
|
+
Resolve volume mount specification.
|
|
204
219
|
|
|
205
220
|
Handles:
|
|
206
221
|
- Relative paths (resolved relative to project_root)
|
|
@@ -208,10 +223,11 @@ class DockerManager:
|
|
|
208
223
|
- Absolute paths (used as-is)
|
|
209
224
|
|
|
210
225
|
Args:
|
|
211
|
-
|
|
226
|
+
volume: Volume specification (e.g., "./src:/workspace/src" or "~/.cargo:/root/.cargo")
|
|
212
227
|
|
|
213
228
|
Returns:
|
|
214
|
-
|
|
229
|
+
Resolved volume specification with absolute host path
|
|
230
|
+
@athena: c7661050443e
|
|
215
231
|
"""
|
|
216
232
|
if ":" not in volume:
|
|
217
233
|
raise ValueError(
|
|
@@ -235,10 +251,12 @@ class DockerManager:
|
|
|
235
251
|
return f"{resolved_host_path}:{container_path}"
|
|
236
252
|
|
|
237
253
|
def _check_docker_available(self) -> None:
|
|
238
|
-
"""
|
|
254
|
+
"""
|
|
255
|
+
Check if docker command is available.
|
|
239
256
|
|
|
240
257
|
Raises:
|
|
241
|
-
|
|
258
|
+
DockerError: If docker is not available
|
|
259
|
+
@athena: 16ba713e3962
|
|
242
260
|
"""
|
|
243
261
|
try:
|
|
244
262
|
subprocess.run(
|
|
@@ -254,16 +272,18 @@ class DockerManager:
|
|
|
254
272
|
)
|
|
255
273
|
|
|
256
274
|
def _get_image_id(self, image_tag: str) -> str:
|
|
257
|
-
"""
|
|
275
|
+
"""
|
|
276
|
+
Get the full image ID for a given tag.
|
|
258
277
|
|
|
259
278
|
Args:
|
|
260
|
-
|
|
279
|
+
image_tag: Docker image tag (e.g., "tt-env-builder")
|
|
261
280
|
|
|
262
281
|
Returns:
|
|
263
|
-
|
|
282
|
+
Full image ID (e.g., "sha256:abc123def456...")
|
|
264
283
|
|
|
265
284
|
Raises:
|
|
266
|
-
|
|
285
|
+
DockerError: If cannot inspect image
|
|
286
|
+
@athena: e4bc075fe857
|
|
267
287
|
"""
|
|
268
288
|
try:
|
|
269
289
|
result = subprocess.run(
|
|
@@ -279,13 +299,15 @@ class DockerManager:
|
|
|
279
299
|
|
|
280
300
|
|
|
281
301
|
def is_docker_environment(env: Environment) -> bool:
|
|
282
|
-
"""
|
|
302
|
+
"""
|
|
303
|
+
Check if environment is Docker-based.
|
|
283
304
|
|
|
284
305
|
Args:
|
|
285
|
-
|
|
306
|
+
env: Environment to check
|
|
286
307
|
|
|
287
308
|
Returns:
|
|
288
|
-
|
|
309
|
+
True if environment has a dockerfile field, False otherwise
|
|
310
|
+
@athena: 1ffd255a4e90
|
|
289
311
|
"""
|
|
290
312
|
return bool(env.dockerfile)
|
|
291
313
|
|
|
@@ -293,7 +315,8 @@ def is_docker_environment(env: Environment) -> bool:
|
|
|
293
315
|
def resolve_container_working_dir(
|
|
294
316
|
env_working_dir: str, task_working_dir: str
|
|
295
317
|
) -> str:
|
|
296
|
-
"""
|
|
318
|
+
"""
|
|
319
|
+
Resolve working directory inside container.
|
|
297
320
|
|
|
298
321
|
Combines environment's working_dir with task's working_dir:
|
|
299
322
|
- If task specifies working_dir: container_dir = env_working_dir / task_working_dir
|
|
@@ -301,11 +324,12 @@ def resolve_container_working_dir(
|
|
|
301
324
|
- If neither specify: container_dir = "/" (Docker default)
|
|
302
325
|
|
|
303
326
|
Args:
|
|
304
|
-
|
|
305
|
-
|
|
327
|
+
env_working_dir: Working directory from environment definition
|
|
328
|
+
task_working_dir: Working directory from task definition
|
|
306
329
|
|
|
307
330
|
Returns:
|
|
308
|
-
|
|
331
|
+
Resolved working directory path
|
|
332
|
+
@athena: bb13d00dd07d
|
|
309
333
|
"""
|
|
310
334
|
if not env_working_dir and not task_working_dir:
|
|
311
335
|
return "/"
|
|
@@ -322,13 +346,15 @@ def resolve_container_working_dir(
|
|
|
322
346
|
|
|
323
347
|
|
|
324
348
|
def parse_dockerignore(dockerignore_path: Path) -> "pathspec.PathSpec | None":
|
|
325
|
-
"""
|
|
349
|
+
"""
|
|
350
|
+
Parse .dockerignore file into pathspec matcher.
|
|
326
351
|
|
|
327
352
|
Args:
|
|
328
|
-
|
|
353
|
+
dockerignore_path: Path to .dockerignore file
|
|
329
354
|
|
|
330
355
|
Returns:
|
|
331
|
-
|
|
356
|
+
PathSpec object for matching, or None if file doesn't exist or pathspec not available
|
|
357
|
+
@athena: 62bc07a3c6d0
|
|
332
358
|
"""
|
|
333
359
|
if pathspec is None:
|
|
334
360
|
# pathspec library not available - can't parse .dockerignore
|
|
@@ -351,17 +377,19 @@ def context_changed_since(
|
|
|
351
377
|
dockerignore_path: Path | None,
|
|
352
378
|
last_run_time: float,
|
|
353
379
|
) -> bool:
|
|
354
|
-
"""
|
|
380
|
+
"""
|
|
381
|
+
Check if any file in Docker build context has changed since last run.
|
|
355
382
|
|
|
356
383
|
Uses early-exit optimization: stops on first changed file found.
|
|
357
384
|
|
|
358
385
|
Args:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
386
|
+
context_path: Path to Docker build context directory
|
|
387
|
+
dockerignore_path: Optional path to .dockerignore file
|
|
388
|
+
last_run_time: Unix timestamp of last task run
|
|
362
389
|
|
|
363
390
|
Returns:
|
|
364
|
-
|
|
391
|
+
True if any file changed, False otherwise
|
|
392
|
+
@athena: 556acb1ed6ca
|
|
365
393
|
"""
|
|
366
394
|
# Parse .dockerignore
|
|
367
395
|
dockerignore_spec = None
|
|
@@ -395,14 +423,16 @@ def context_changed_since(
|
|
|
395
423
|
|
|
396
424
|
|
|
397
425
|
def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]:
|
|
398
|
-
"""
|
|
426
|
+
"""
|
|
427
|
+
Extract image references from FROM lines in Dockerfile.
|
|
399
428
|
|
|
400
429
|
Args:
|
|
401
|
-
|
|
430
|
+
dockerfile_content: Content of Dockerfile
|
|
402
431
|
|
|
403
432
|
Returns:
|
|
404
|
-
|
|
405
|
-
|
|
433
|
+
List of (image_reference, digest) tuples where digest may be None for unpinned images
|
|
434
|
+
Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
|
|
435
|
+
@athena: ede7ed483bdd
|
|
406
436
|
"""
|
|
407
437
|
# Regex pattern to match FROM lines
|
|
408
438
|
# Handles: FROM [--platform=...] image[:tag][@digest] [AS alias]
|
|
@@ -421,26 +451,30 @@ def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]
|
|
|
421
451
|
|
|
422
452
|
|
|
423
453
|
def check_unpinned_images(dockerfile_content: str) -> list[str]:
|
|
424
|
-
"""
|
|
454
|
+
"""
|
|
455
|
+
Check for unpinned base images in Dockerfile.
|
|
425
456
|
|
|
426
457
|
Args:
|
|
427
|
-
|
|
458
|
+
dockerfile_content: Content of Dockerfile
|
|
428
459
|
|
|
429
460
|
Returns:
|
|
430
|
-
|
|
461
|
+
List of unpinned image references (images without @sha256:... digests)
|
|
462
|
+
@athena: 58cc6de8fc96
|
|
431
463
|
"""
|
|
432
464
|
images = extract_from_images(dockerfile_content)
|
|
433
465
|
return [image for image, digest in images if digest is None]
|
|
434
466
|
|
|
435
467
|
|
|
436
468
|
def parse_base_image_digests(dockerfile_content: str) -> list[str]:
|
|
437
|
-
"""
|
|
469
|
+
"""
|
|
470
|
+
Parse pinned base image digests from Dockerfile.
|
|
438
471
|
|
|
439
472
|
Args:
|
|
440
|
-
|
|
473
|
+
dockerfile_content: Content of Dockerfile
|
|
441
474
|
|
|
442
475
|
Returns:
|
|
443
|
-
|
|
476
|
+
List of digests (e.g., ["sha256:abc123...", "sha256:def456..."])
|
|
477
|
+
@athena: c4d1da6b067c
|
|
444
478
|
"""
|
|
445
479
|
images = extract_from_images(dockerfile_content)
|
|
446
480
|
return [digest for _image, digest in images if digest is not None]
|