tasktree 0.0.5__py3-none-any.whl → 0.0.7__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/cli.py +66 -31
- tasktree/docker.py +413 -0
- tasktree/executor.py +268 -31
- tasktree/graph.py +30 -1
- tasktree/hasher.py +27 -0
- tasktree/parser.py +118 -15
- tasktree/state.py +1 -1
- {tasktree-0.0.5.dist-info → tasktree-0.0.7.dist-info}/METADATA +33 -47
- tasktree-0.0.7.dist-info/RECORD +14 -0
- tasktree-0.0.5.dist-info/RECORD +0 -13
- {tasktree-0.0.5.dist-info → tasktree-0.0.7.dist-info}/WHEEL +0 -0
- {tasktree-0.0.5.dist-info → tasktree-0.0.7.dist-info}/entry_points.txt +0 -0
tasktree/cli.py
CHANGED
|
@@ -26,11 +26,11 @@ app = typer.Typer(
|
|
|
26
26
|
console = Console()
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def _list_tasks():
|
|
29
|
+
def _list_tasks(tasks_file: Optional[str] = None):
|
|
30
30
|
"""List all available tasks with descriptions."""
|
|
31
|
-
recipe = _get_recipe()
|
|
31
|
+
recipe = _get_recipe(tasks_file)
|
|
32
32
|
if recipe is None:
|
|
33
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
33
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
34
34
|
raise typer.Exit(1)
|
|
35
35
|
|
|
36
36
|
table = Table(title="Available Tasks")
|
|
@@ -45,11 +45,11 @@ def _list_tasks():
|
|
|
45
45
|
console.print(table)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def _show_task(task_name: str):
|
|
48
|
+
def _show_task(task_name: str, tasks_file: Optional[str] = None):
|
|
49
49
|
"""Show task definition with syntax highlighting."""
|
|
50
|
-
recipe = _get_recipe()
|
|
50
|
+
recipe = _get_recipe(tasks_file)
|
|
51
51
|
if recipe is None:
|
|
52
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
52
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
53
53
|
raise typer.Exit(1)
|
|
54
54
|
|
|
55
55
|
task = recipe.get_task(task_name)
|
|
@@ -79,17 +79,26 @@ def _show_task(task_name: str):
|
|
|
79
79
|
task_dict = task_yaml[task_name]
|
|
80
80
|
task_yaml[task_name] = {k: v for k, v in task_dict.items() if v}
|
|
81
81
|
|
|
82
|
+
# Configure YAML dumper to use literal block style for multiline strings
|
|
83
|
+
def literal_presenter(dumper, data):
|
|
84
|
+
"""Use literal block style (|) for strings containing newlines."""
|
|
85
|
+
if '\n' in data:
|
|
86
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
|
|
87
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', data)
|
|
88
|
+
|
|
89
|
+
yaml.add_representer(str, literal_presenter)
|
|
90
|
+
|
|
82
91
|
# Format and highlight using Rich
|
|
83
92
|
yaml_str = yaml.dump(task_yaml, default_flow_style=False, sort_keys=False)
|
|
84
93
|
syntax = Syntax(yaml_str, "yaml", theme="ansi_light", line_numbers=False)
|
|
85
94
|
console.print(syntax)
|
|
86
95
|
|
|
87
96
|
|
|
88
|
-
def _show_tree(task_name: str):
|
|
97
|
+
def _show_tree(task_name: str, tasks_file: Optional[str] = None):
|
|
89
98
|
"""Show dependency tree structure."""
|
|
90
|
-
recipe = _get_recipe()
|
|
99
|
+
recipe = _get_recipe(tasks_file)
|
|
91
100
|
if recipe is None:
|
|
92
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
101
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
93
102
|
raise typer.Exit(1)
|
|
94
103
|
|
|
95
104
|
task = recipe.get_task(task_name)
|
|
@@ -169,6 +178,7 @@ def main(
|
|
|
169
178
|
list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
|
|
170
179
|
show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
|
|
171
180
|
tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
|
|
181
|
+
tasks_file: Optional[str] = typer.Option(None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"),
|
|
172
182
|
init: Optional[bool] = typer.Option(
|
|
173
183
|
None, "--init", "-i", help="Create a blank tasktree.yaml"
|
|
174
184
|
),
|
|
@@ -208,15 +218,15 @@ def main(
|
|
|
208
218
|
"""
|
|
209
219
|
|
|
210
220
|
if list_opt:
|
|
211
|
-
_list_tasks()
|
|
221
|
+
_list_tasks(tasks_file)
|
|
212
222
|
raise typer.Exit()
|
|
213
223
|
|
|
214
224
|
if show:
|
|
215
|
-
_show_task(show)
|
|
225
|
+
_show_task(show, tasks_file)
|
|
216
226
|
raise typer.Exit()
|
|
217
227
|
|
|
218
228
|
if tree:
|
|
219
|
-
_show_tree(tree)
|
|
229
|
+
_show_tree(tree, tasks_file)
|
|
220
230
|
raise typer.Exit()
|
|
221
231
|
|
|
222
232
|
if init:
|
|
@@ -224,17 +234,17 @@ def main(
|
|
|
224
234
|
raise typer.Exit()
|
|
225
235
|
|
|
226
236
|
if clean or clean_state or reset:
|
|
227
|
-
_clean_state()
|
|
237
|
+
_clean_state(tasks_file)
|
|
228
238
|
raise typer.Exit()
|
|
229
239
|
|
|
230
240
|
if task_args:
|
|
231
241
|
# --only implies --force
|
|
232
242
|
force_execution = force or only or False
|
|
233
|
-
_execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env)
|
|
243
|
+
_execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env, tasks_file=tasks_file)
|
|
234
244
|
else:
|
|
235
|
-
recipe = _get_recipe()
|
|
245
|
+
recipe = _get_recipe(tasks_file)
|
|
236
246
|
if recipe is None:
|
|
237
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
247
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
238
248
|
console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
|
|
239
249
|
raise typer.Exit(1)
|
|
240
250
|
|
|
@@ -245,13 +255,19 @@ def main(
|
|
|
245
255
|
console.print("Use [cyan]tt <task-name>[/cyan] to run a task")
|
|
246
256
|
|
|
247
257
|
|
|
248
|
-
def _clean_state() -> None:
|
|
258
|
+
def _clean_state(tasks_file: Optional[str] = None) -> None:
|
|
249
259
|
"""Remove the .tasktree-state file to reset task execution state."""
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
260
|
+
if tasks_file:
|
|
261
|
+
recipe_path = Path(tasks_file)
|
|
262
|
+
if not recipe_path.exists():
|
|
263
|
+
console.print(f"[red]Recipe file not found: {tasks_file}[/red]")
|
|
264
|
+
raise typer.Exit(1)
|
|
265
|
+
else:
|
|
266
|
+
recipe_path = find_recipe_file()
|
|
267
|
+
if recipe_path is None:
|
|
268
|
+
console.print("[yellow]No recipe file found[/yellow]")
|
|
269
|
+
console.print("State file location depends on recipe file location")
|
|
270
|
+
raise typer.Exit(1)
|
|
255
271
|
|
|
256
272
|
project_root = recipe_path.parent
|
|
257
273
|
state_path = project_root / ".tasktree-state"
|
|
@@ -264,29 +280,48 @@ def _clean_state() -> None:
|
|
|
264
280
|
console.print(f"[yellow]No state file found at {state_path}[/yellow]")
|
|
265
281
|
|
|
266
282
|
|
|
267
|
-
def _get_recipe() -> Optional[Recipe]:
|
|
268
|
-
"""Get parsed recipe or None if not found.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
283
|
+
def _get_recipe(recipe_file: Optional[str] = None) -> Optional[Recipe]:
|
|
284
|
+
"""Get parsed recipe or None if not found.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
|
|
288
|
+
"""
|
|
289
|
+
if recipe_file:
|
|
290
|
+
recipe_path = Path(recipe_file)
|
|
291
|
+
if not recipe_path.exists():
|
|
292
|
+
console.print(f"[red]Recipe file not found: {recipe_file}[/red]")
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
# When explicitly specified, project root is current working directory
|
|
295
|
+
project_root = Path.cwd()
|
|
296
|
+
else:
|
|
297
|
+
try:
|
|
298
|
+
recipe_path = find_recipe_file()
|
|
299
|
+
if recipe_path is None:
|
|
300
|
+
return None
|
|
301
|
+
except ValueError as e:
|
|
302
|
+
# Multiple recipe files found
|
|
303
|
+
console.print(f"[red]{e}[/red]")
|
|
304
|
+
raise typer.Exit(1)
|
|
305
|
+
# When auto-discovered, project root is recipe file's parent
|
|
306
|
+
project_root = None
|
|
272
307
|
|
|
273
308
|
try:
|
|
274
|
-
return parse_recipe(recipe_path)
|
|
309
|
+
return parse_recipe(recipe_path, project_root)
|
|
275
310
|
except Exception as e:
|
|
276
311
|
console.print(f"[red]Error parsing recipe: {e}[/red]")
|
|
277
312
|
raise typer.Exit(1)
|
|
278
313
|
|
|
279
314
|
|
|
280
|
-
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None) -> None:
|
|
315
|
+
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None, tasks_file: Optional[str] = None) -> None:
|
|
281
316
|
if not args:
|
|
282
317
|
return
|
|
283
318
|
|
|
284
319
|
task_name = args[0]
|
|
285
320
|
task_args = args[1:]
|
|
286
321
|
|
|
287
|
-
recipe = _get_recipe()
|
|
322
|
+
recipe = _get_recipe(tasks_file)
|
|
288
323
|
if recipe is None:
|
|
289
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
324
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
290
325
|
raise typer.Exit(1)
|
|
291
326
|
|
|
292
327
|
# Apply global environment override if provided
|
tasktree/docker.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Docker integration for Task Tree.
|
|
2
|
+
|
|
3
|
+
Provides Docker image building and container execution capabilities.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import pathspec
|
|
17
|
+
except ImportError:
|
|
18
|
+
pathspec = None # type: ignore
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from tasktree.parser import Environment
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DockerError(Exception):
|
|
25
|
+
"""Raised when Docker operations fail."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DockerManager:
|
|
31
|
+
"""Manages Docker image building and container execution."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, project_root: Path):
|
|
34
|
+
"""Initialize Docker manager.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
project_root: Root directory of the project (where tasktree.yaml is located)
|
|
38
|
+
"""
|
|
39
|
+
self._project_root = project_root
|
|
40
|
+
self._built_images: dict[str, tuple[str, str]] = {} # env_name -> (image_tag, image_id) cache
|
|
41
|
+
|
|
42
|
+
def ensure_image_built(self, env: Environment) -> tuple[str, str]:
|
|
43
|
+
"""Build Docker image if not already built this invocation.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
env: Environment definition with dockerfile and context
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Tuple of (image_tag, image_id)
|
|
50
|
+
- image_tag: Tag like "tt-env-builder"
|
|
51
|
+
- image_id: Full image ID like "sha256:abc123..."
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
DockerError: If docker command not available or build fails
|
|
55
|
+
"""
|
|
56
|
+
# Check if already built this invocation
|
|
57
|
+
if env.name in self._built_images:
|
|
58
|
+
tag, image_id = self._built_images[env.name]
|
|
59
|
+
return tag, image_id
|
|
60
|
+
|
|
61
|
+
# Check if docker is available
|
|
62
|
+
self._check_docker_available()
|
|
63
|
+
|
|
64
|
+
# Resolve paths
|
|
65
|
+
dockerfile_path = self._project_root / env.dockerfile
|
|
66
|
+
context_path = self._project_root / env.context
|
|
67
|
+
|
|
68
|
+
# Generate image tag
|
|
69
|
+
image_tag = f"tt-env-{env.name}"
|
|
70
|
+
|
|
71
|
+
# Build the image
|
|
72
|
+
try:
|
|
73
|
+
subprocess.run(
|
|
74
|
+
[
|
|
75
|
+
"docker",
|
|
76
|
+
"build",
|
|
77
|
+
"-t",
|
|
78
|
+
image_tag,
|
|
79
|
+
"-f",
|
|
80
|
+
str(dockerfile_path),
|
|
81
|
+
str(context_path),
|
|
82
|
+
],
|
|
83
|
+
check=True,
|
|
84
|
+
capture_output=False, # Show build output to user
|
|
85
|
+
)
|
|
86
|
+
except subprocess.CalledProcessError as e:
|
|
87
|
+
raise DockerError(
|
|
88
|
+
f"Failed to build Docker image for environment '{env.name}': "
|
|
89
|
+
f"docker build exited with code {e.returncode}"
|
|
90
|
+
) from e
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
raise DockerError(
|
|
93
|
+
"Docker command not found. Please install Docker and ensure it's in your PATH."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Get the image ID
|
|
97
|
+
image_id = self._get_image_id(image_tag)
|
|
98
|
+
|
|
99
|
+
# Cache both tag and ID
|
|
100
|
+
self._built_images[env.name] = (image_tag, image_id)
|
|
101
|
+
return image_tag, image_id
|
|
102
|
+
|
|
103
|
+
def run_in_container(
|
|
104
|
+
self,
|
|
105
|
+
env: Environment,
|
|
106
|
+
cmd: str,
|
|
107
|
+
working_dir: Path,
|
|
108
|
+
container_working_dir: str,
|
|
109
|
+
) -> subprocess.CompletedProcess:
|
|
110
|
+
"""Execute command inside Docker container.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
env: Environment definition
|
|
114
|
+
cmd: Command to execute
|
|
115
|
+
working_dir: Host working directory (for resolving relative volume paths)
|
|
116
|
+
container_working_dir: Working directory inside container
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
CompletedProcess from subprocess.run
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
DockerError: If docker run fails
|
|
123
|
+
"""
|
|
124
|
+
# Ensure image is built (returns tag and ID)
|
|
125
|
+
image_tag, image_id = self.ensure_image_built(env)
|
|
126
|
+
|
|
127
|
+
# Build docker run command
|
|
128
|
+
docker_cmd = ["docker", "run", "--rm"]
|
|
129
|
+
|
|
130
|
+
# Add volume mounts
|
|
131
|
+
for volume in env.volumes:
|
|
132
|
+
# Resolve volume paths
|
|
133
|
+
resolved_volume = self._resolve_volume_mount(volume)
|
|
134
|
+
docker_cmd.extend(["-v", resolved_volume])
|
|
135
|
+
|
|
136
|
+
# Add port mappings
|
|
137
|
+
for port in env.ports:
|
|
138
|
+
docker_cmd.extend(["-p", port])
|
|
139
|
+
|
|
140
|
+
# Add environment variables
|
|
141
|
+
for var_name, var_value in env.env_vars.items():
|
|
142
|
+
docker_cmd.extend(["-e", f"{var_name}={var_value}"])
|
|
143
|
+
|
|
144
|
+
# Add working directory
|
|
145
|
+
if container_working_dir:
|
|
146
|
+
docker_cmd.extend(["-w", container_working_dir])
|
|
147
|
+
|
|
148
|
+
# Add image tag
|
|
149
|
+
docker_cmd.append(image_tag)
|
|
150
|
+
|
|
151
|
+
# Add shell and command
|
|
152
|
+
shell = env.shell or "sh"
|
|
153
|
+
docker_cmd.extend([shell, "-c", cmd])
|
|
154
|
+
|
|
155
|
+
# Execute
|
|
156
|
+
try:
|
|
157
|
+
result = subprocess.run(
|
|
158
|
+
docker_cmd,
|
|
159
|
+
cwd=working_dir,
|
|
160
|
+
check=True,
|
|
161
|
+
capture_output=False, # Stream output to terminal
|
|
162
|
+
)
|
|
163
|
+
return result
|
|
164
|
+
except subprocess.CalledProcessError as e:
|
|
165
|
+
raise DockerError(
|
|
166
|
+
f"Docker container execution failed with exit code {e.returncode}"
|
|
167
|
+
) from e
|
|
168
|
+
|
|
169
|
+
def _resolve_volume_mount(self, volume: str) -> str:
|
|
170
|
+
"""Resolve volume mount specification.
|
|
171
|
+
|
|
172
|
+
Handles:
|
|
173
|
+
- Relative paths (resolved relative to project_root)
|
|
174
|
+
- Home directory expansion (~)
|
|
175
|
+
- Absolute paths (used as-is)
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
volume: Volume specification (e.g., "./src:/workspace/src" or "~/.cargo:/root/.cargo")
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Resolved volume specification with absolute host path
|
|
182
|
+
"""
|
|
183
|
+
if ":" not in volume:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"Invalid volume specification: '{volume}'. "
|
|
186
|
+
f"Format should be 'host_path:container_path'"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
host_path, container_path = volume.split(":", 1)
|
|
190
|
+
|
|
191
|
+
# Expand home directory
|
|
192
|
+
if host_path.startswith("~"):
|
|
193
|
+
host_path = os.path.expanduser(host_path)
|
|
194
|
+
resolved_host_path = Path(host_path)
|
|
195
|
+
# Resolve relative paths
|
|
196
|
+
elif not Path(host_path).is_absolute():
|
|
197
|
+
resolved_host_path = self._project_root / host_path
|
|
198
|
+
# Absolute paths used as-is
|
|
199
|
+
else:
|
|
200
|
+
resolved_host_path = Path(host_path)
|
|
201
|
+
|
|
202
|
+
return f"{resolved_host_path}:{container_path}"
|
|
203
|
+
|
|
204
|
+
def _check_docker_available(self) -> None:
|
|
205
|
+
"""Check if docker command is available.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
DockerError: If docker is not available
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
subprocess.run(
|
|
212
|
+
["docker", "--version"],
|
|
213
|
+
check=True,
|
|
214
|
+
capture_output=True,
|
|
215
|
+
text=True,
|
|
216
|
+
)
|
|
217
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
218
|
+
raise DockerError(
|
|
219
|
+
"Docker is not available. Please install Docker and ensure it's running.\n"
|
|
220
|
+
"Visit https://docs.docker.com/get-docker/ for installation instructions."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _get_image_id(self, image_tag: str) -> str:
|
|
224
|
+
"""Get the full image ID for a given tag.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
image_tag: Docker image tag (e.g., "tt-env-builder")
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Full image ID (e.g., "sha256:abc123def456...")
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
DockerError: If cannot inspect image
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
result = subprocess.run(
|
|
237
|
+
["docker", "inspect", "--format={{.Id}}", image_tag],
|
|
238
|
+
check=True,
|
|
239
|
+
capture_output=True,
|
|
240
|
+
text=True,
|
|
241
|
+
)
|
|
242
|
+
image_id = result.stdout.strip()
|
|
243
|
+
return image_id
|
|
244
|
+
except subprocess.CalledProcessError as e:
|
|
245
|
+
raise DockerError(f"Failed to inspect image {image_tag}: {e.stderr}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def is_docker_environment(env: Environment) -> bool:
|
|
249
|
+
"""Check if environment is Docker-based.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
env: Environment to check
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
True if environment has a dockerfile field, False otherwise
|
|
256
|
+
"""
|
|
257
|
+
return bool(env.dockerfile)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def resolve_container_working_dir(
|
|
261
|
+
env_working_dir: str, task_working_dir: str
|
|
262
|
+
) -> str:
|
|
263
|
+
"""Resolve working directory inside container.
|
|
264
|
+
|
|
265
|
+
Combines environment's working_dir with task's working_dir:
|
|
266
|
+
- If task specifies working_dir: container_dir = env_working_dir / task_working_dir
|
|
267
|
+
- If task doesn't specify: container_dir = env_working_dir
|
|
268
|
+
- If neither specify: container_dir = "/" (Docker default)
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
env_working_dir: Working directory from environment definition
|
|
272
|
+
task_working_dir: Working directory from task definition
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Resolved working directory path
|
|
276
|
+
"""
|
|
277
|
+
if not env_working_dir and not task_working_dir:
|
|
278
|
+
return "/"
|
|
279
|
+
|
|
280
|
+
if not task_working_dir:
|
|
281
|
+
return env_working_dir
|
|
282
|
+
|
|
283
|
+
# Combine paths
|
|
284
|
+
if env_working_dir:
|
|
285
|
+
# Join paths using POSIX separator (works inside Linux containers)
|
|
286
|
+
return f"{env_working_dir.rstrip('/')}/{task_working_dir.lstrip('/')}"
|
|
287
|
+
else:
|
|
288
|
+
return f"/{task_working_dir.lstrip('/')}"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def parse_dockerignore(dockerignore_path: Path) -> "pathspec.PathSpec | None":
|
|
292
|
+
"""Parse .dockerignore file into pathspec matcher.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
dockerignore_path: Path to .dockerignore file
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
PathSpec object for matching, or None if file doesn't exist or pathspec not available
|
|
299
|
+
"""
|
|
300
|
+
if pathspec is None:
|
|
301
|
+
# pathspec library not available - can't parse .dockerignore
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
if not dockerignore_path.exists():
|
|
305
|
+
return pathspec.PathSpec([]) # Empty matcher
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
with open(dockerignore_path, "r") as f:
|
|
309
|
+
spec = pathspec.PathSpec.from_lines("gitwildmatch", f)
|
|
310
|
+
return spec
|
|
311
|
+
except Exception:
|
|
312
|
+
# Invalid patterns - return empty matcher rather than failing
|
|
313
|
+
return pathspec.PathSpec([])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def context_changed_since(
|
|
317
|
+
context_path: Path,
|
|
318
|
+
dockerignore_path: Path | None,
|
|
319
|
+
last_run_time: float,
|
|
320
|
+
) -> bool:
|
|
321
|
+
"""Check if any file in Docker build context has changed since last run.
|
|
322
|
+
|
|
323
|
+
Uses early-exit optimization: stops on first changed file found.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
context_path: Path to Docker build context directory
|
|
327
|
+
dockerignore_path: Optional path to .dockerignore file
|
|
328
|
+
last_run_time: Unix timestamp of last task run
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
True if any file changed, False otherwise
|
|
332
|
+
"""
|
|
333
|
+
# Parse .dockerignore
|
|
334
|
+
dockerignore_spec = None
|
|
335
|
+
if dockerignore_path:
|
|
336
|
+
dockerignore_spec = parse_dockerignore(dockerignore_path)
|
|
337
|
+
|
|
338
|
+
# Walk context directory
|
|
339
|
+
for file_path in context_path.rglob("*"):
|
|
340
|
+
if not file_path.is_file():
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
# Check if file matches .dockerignore patterns
|
|
344
|
+
if dockerignore_spec:
|
|
345
|
+
try:
|
|
346
|
+
relative_path = file_path.relative_to(context_path)
|
|
347
|
+
if dockerignore_spec.match_file(str(relative_path)):
|
|
348
|
+
continue # Skip ignored files
|
|
349
|
+
except ValueError:
|
|
350
|
+
# File not relative to context (shouldn't happen with rglob)
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
# Check if file changed (early exit)
|
|
354
|
+
try:
|
|
355
|
+
if file_path.stat().st_mtime > last_run_time:
|
|
356
|
+
return True # Found a changed file
|
|
357
|
+
except (OSError, FileNotFoundError):
|
|
358
|
+
# File might have been deleted - consider it changed
|
|
359
|
+
return True
|
|
360
|
+
|
|
361
|
+
return False # No changes found
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]:
|
|
365
|
+
"""Extract image references from FROM lines in Dockerfile.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
dockerfile_content: Content of Dockerfile
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
List of (image_reference, digest) tuples where digest may be None for unpinned images
|
|
372
|
+
Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
|
|
373
|
+
"""
|
|
374
|
+
# Regex pattern to match FROM lines
|
|
375
|
+
# Handles: FROM [--platform=...] image[:tag][@digest] [AS alias]
|
|
376
|
+
from_pattern = re.compile(
|
|
377
|
+
r"^\s*FROM\s+" # FROM keyword
|
|
378
|
+
r"(?:--platform=[^\s]+\s+)?" # Optional platform flag
|
|
379
|
+
r"([^\s@]+)" # Image name (possibly with :tag)
|
|
380
|
+
r"(?:@(sha256:[a-f0-9]+))?" # Optional @digest
|
|
381
|
+
r"(?:\s+AS\s+\w+)?" # Optional AS alias
|
|
382
|
+
r"\s*$",
|
|
383
|
+
re.MULTILINE | re.IGNORECASE,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
matches = from_pattern.findall(dockerfile_content)
|
|
387
|
+
return [(image, digest if digest else None) for image, digest in matches]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def check_unpinned_images(dockerfile_content: str) -> list[str]:
|
|
391
|
+
"""Check for unpinned base images in Dockerfile.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
dockerfile_content: Content of Dockerfile
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
List of unpinned image references (images without @sha256:... digests)
|
|
398
|
+
"""
|
|
399
|
+
images = extract_from_images(dockerfile_content)
|
|
400
|
+
return [image for image, digest in images if digest is None]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def parse_base_image_digests(dockerfile_content: str) -> list[str]:
|
|
404
|
+
"""Parse pinned base image digests from Dockerfile.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
dockerfile_content: Content of Dockerfile
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
List of digests (e.g., ["sha256:abc123...", "sha256:def456..."])
|
|
411
|
+
"""
|
|
412
|
+
images = extract_from_images(dockerfile_content)
|
|
413
|
+
return [digest for _image, digest in images if digest is not None]
|