tasktree 0.0.6__py3-none-any.whl → 0.0.8__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 CHANGED
@@ -375,8 +375,8 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
375
375
 
376
376
  parsed_specs = []
377
377
  for spec in arg_specs:
378
- name, arg_type, default = parse_arg_spec(spec)
379
- parsed_specs.append((name, arg_type, default))
378
+ parsed = parse_arg_spec(spec)
379
+ parsed_specs.append(parsed)
380
380
 
381
381
  args_dict = {}
382
382
  positional_index = 0
@@ -386,41 +386,49 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
386
386
  if "=" in value_str:
387
387
  arg_name, arg_value = value_str.split("=", 1)
388
388
  # Find the spec for this argument
389
- spec = next((s for s in parsed_specs if s[0] == arg_name), None)
389
+ spec = next((s for s in parsed_specs if s.name == arg_name), None)
390
390
  if spec is None:
391
391
  console.print(f"[red]Unknown argument: {arg_name}[/red]")
392
392
  raise typer.Exit(1)
393
- name, arg_type, default = spec
394
393
  else:
395
394
  # Positional argument
396
395
  if positional_index >= len(parsed_specs):
397
396
  console.print(f"[red]Too many arguments[/red]")
398
397
  raise typer.Exit(1)
399
- name, arg_type, default = parsed_specs[positional_index]
398
+ spec = parsed_specs[positional_index]
400
399
  arg_value = value_str
401
400
  positional_index += 1
402
401
 
403
- # Convert value to appropriate type
402
+ # Convert value to appropriate type (exported args are always strings)
404
403
  try:
405
- click_type = get_click_type(arg_type)
404
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
406
405
  converted_value = click_type.convert(arg_value, None, None)
407
- args_dict[name] = converted_value
406
+
407
+ # Validate choices after type conversion
408
+ if spec.choices is not None and converted_value not in spec.choices:
409
+ console.print(f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]")
410
+ console.print(f"Valid choices: {', '.join(repr(c) for c in spec.choices)}")
411
+ raise typer.Exit(1)
412
+
413
+ args_dict[spec.name] = converted_value
414
+ except typer.Exit:
415
+ raise # Re-raise typer.Exit without wrapping
408
416
  except Exception as e:
409
- console.print(f"[red]Invalid value for {name}: {e}[/red]")
417
+ console.print(f"[red]Invalid value for {spec.name}: {e}[/red]")
410
418
  raise typer.Exit(1)
411
419
 
412
420
  # Fill in defaults for missing arguments
413
- for name, arg_type, default in parsed_specs:
414
- if name not in args_dict:
415
- if default is not None:
421
+ for spec in parsed_specs:
422
+ if spec.name not in args_dict:
423
+ if spec.default is not None:
416
424
  try:
417
- click_type = get_click_type(arg_type)
418
- args_dict[name] = click_type.convert(default, None, None)
425
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
426
+ args_dict[spec.name] = click_type.convert(spec.default, None, None)
419
427
  except Exception as e:
420
- console.print(f"[red]Invalid default value for {name}: {e}[/red]")
428
+ console.print(f"[red]Invalid default value for {spec.name}: {e}[/red]")
421
429
  raise typer.Exit(1)
422
430
  else:
423
- console.print(f"[red]Missing required argument: {name}[/red]")
431
+ console.print(f"[red]Missing required argument: {spec.name}[/red]")
424
432
  raise typer.Exit(1)
425
433
 
426
434
  return args_dict
tasktree/docker.py ADDED
@@ -0,0 +1,438 @@
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 platform
10
+ import re
11
+ import subprocess
12
+ import time
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ try:
17
+ import pathspec
18
+ except ImportError:
19
+ pathspec = None # type: ignore
20
+
21
+ if TYPE_CHECKING:
22
+ from tasktree.parser import Environment
23
+
24
+
25
+ class DockerError(Exception):
26
+ """Raised when Docker operations fail."""
27
+
28
+ pass
29
+
30
+
31
+ class DockerManager:
32
+ """Manages Docker image building and container execution."""
33
+
34
+ def __init__(self, project_root: Path):
35
+ """Initialize Docker manager.
36
+
37
+ Args:
38
+ project_root: Root directory of the project (where tasktree.yaml is located)
39
+ """
40
+ self._project_root = project_root
41
+ self._built_images: dict[str, tuple[str, str]] = {} # env_name -> (image_tag, image_id) cache
42
+
43
+ def _should_add_user_flag(self) -> bool:
44
+ """Check if --user flag should be added to docker run.
45
+
46
+ Returns False on Windows (where Docker Desktop handles UID mapping automatically).
47
+ Returns True on Linux/macOS where os.getuid() and os.getgid() are available.
48
+
49
+ Returns:
50
+ True if --user flag should be added, False otherwise
51
+ """
52
+ # Skip on Windows - Docker Desktop handles UID mapping differently
53
+ if platform.system() == "Windows":
54
+ return False
55
+
56
+ # Check if os.getuid() and os.getgid() are available (Linux/macOS)
57
+ return hasattr(os, "getuid") and hasattr(os, "getgid")
58
+
59
+ def ensure_image_built(self, env: Environment) -> tuple[str, str]:
60
+ """Build Docker image if not already built this invocation.
61
+
62
+ Args:
63
+ env: Environment definition with dockerfile and context
64
+
65
+ Returns:
66
+ Tuple of (image_tag, image_id)
67
+ - image_tag: Tag like "tt-env-builder"
68
+ - image_id: Full image ID like "sha256:abc123..."
69
+
70
+ Raises:
71
+ DockerError: If docker command not available or build fails
72
+ """
73
+ # Check if already built this invocation
74
+ if env.name in self._built_images:
75
+ tag, image_id = self._built_images[env.name]
76
+ return tag, image_id
77
+
78
+ # Check if docker is available
79
+ self._check_docker_available()
80
+
81
+ # Resolve paths
82
+ dockerfile_path = self._project_root / env.dockerfile
83
+ context_path = self._project_root / env.context
84
+
85
+ # Generate image tag
86
+ image_tag = f"tt-env-{env.name}"
87
+
88
+ # Build the image
89
+ try:
90
+ subprocess.run(
91
+ [
92
+ "docker",
93
+ "build",
94
+ "-t",
95
+ image_tag,
96
+ "-f",
97
+ str(dockerfile_path),
98
+ str(context_path),
99
+ ],
100
+ check=True,
101
+ capture_output=False, # Show build output to user
102
+ )
103
+ except subprocess.CalledProcessError as e:
104
+ raise DockerError(
105
+ f"Failed to build Docker image for environment '{env.name}': "
106
+ f"docker build exited with code {e.returncode}"
107
+ ) from e
108
+ except FileNotFoundError:
109
+ raise DockerError(
110
+ "Docker command not found. Please install Docker and ensure it's in your PATH."
111
+ )
112
+
113
+ # Get the image ID
114
+ image_id = self._get_image_id(image_tag)
115
+
116
+ # Cache both tag and ID
117
+ self._built_images[env.name] = (image_tag, image_id)
118
+ return image_tag, image_id
119
+
120
+ def run_in_container(
121
+ self,
122
+ env: Environment,
123
+ cmd: str,
124
+ working_dir: Path,
125
+ container_working_dir: str,
126
+ ) -> subprocess.CompletedProcess:
127
+ """Execute command inside Docker container.
128
+
129
+ Args:
130
+ env: Environment definition
131
+ cmd: Command to execute
132
+ working_dir: Host working directory (for resolving relative volume paths)
133
+ container_working_dir: Working directory inside container
134
+
135
+ Returns:
136
+ CompletedProcess from subprocess.run
137
+
138
+ Raises:
139
+ DockerError: If docker run fails
140
+ """
141
+ # Ensure image is built (returns tag and ID)
142
+ image_tag, image_id = self.ensure_image_built(env)
143
+
144
+ # Build docker run command
145
+ docker_cmd = ["docker", "run", "--rm"]
146
+
147
+ # Add user mapping (run as current host user) unless explicitly disabled or on Windows
148
+ if not env.run_as_root and self._should_add_user_flag():
149
+ uid = os.getuid()
150
+ gid = os.getgid()
151
+ docker_cmd.extend(["--user", f"{uid}:{gid}"])
152
+
153
+ docker_cmd.extend(env.extra_args)
154
+
155
+ # Add volume mounts
156
+ for volume in env.volumes:
157
+ # Resolve volume paths
158
+ resolved_volume = self._resolve_volume_mount(volume)
159
+ docker_cmd.extend(["-v", resolved_volume])
160
+
161
+ # Add port mappings
162
+ for port in env.ports:
163
+ docker_cmd.extend(["-p", port])
164
+
165
+ # Add environment variables
166
+ for var_name, var_value in env.env_vars.items():
167
+ docker_cmd.extend(["-e", f"{var_name}={var_value}"])
168
+
169
+ # Add working directory
170
+ if container_working_dir:
171
+ docker_cmd.extend(["-w", container_working_dir])
172
+
173
+ # Add image tag
174
+ docker_cmd.append(image_tag)
175
+
176
+ # Add shell and command
177
+ shell = env.shell or "sh"
178
+ docker_cmd.extend([shell, "-c", cmd])
179
+
180
+ # Execute
181
+ try:
182
+ result = subprocess.run(
183
+ docker_cmd,
184
+ cwd=working_dir,
185
+ check=True,
186
+ capture_output=False, # Stream output to terminal
187
+ )
188
+ return result
189
+ except subprocess.CalledProcessError as e:
190
+ raise DockerError(
191
+ f"Docker container execution failed with exit code {e.returncode}"
192
+ ) from e
193
+
194
+ def _resolve_volume_mount(self, volume: str) -> str:
195
+ """Resolve volume mount specification.
196
+
197
+ Handles:
198
+ - Relative paths (resolved relative to project_root)
199
+ - Home directory expansion (~)
200
+ - Absolute paths (used as-is)
201
+
202
+ Args:
203
+ volume: Volume specification (e.g., "./src:/workspace/src" or "~/.cargo:/root/.cargo")
204
+
205
+ Returns:
206
+ Resolved volume specification with absolute host path
207
+ """
208
+ if ":" not in volume:
209
+ raise ValueError(
210
+ f"Invalid volume specification: '{volume}'. "
211
+ f"Format should be 'host_path:container_path'"
212
+ )
213
+
214
+ host_path, container_path = volume.split(":", 1)
215
+
216
+ # Expand home directory
217
+ if host_path.startswith("~"):
218
+ host_path = os.path.expanduser(host_path)
219
+ resolved_host_path = Path(host_path)
220
+ # Resolve relative paths
221
+ elif not Path(host_path).is_absolute():
222
+ resolved_host_path = self._project_root / host_path
223
+ # Absolute paths used as-is
224
+ else:
225
+ resolved_host_path = Path(host_path)
226
+
227
+ return f"{resolved_host_path}:{container_path}"
228
+
229
+ def _check_docker_available(self) -> None:
230
+ """Check if docker command is available.
231
+
232
+ Raises:
233
+ DockerError: If docker is not available
234
+ """
235
+ try:
236
+ subprocess.run(
237
+ ["docker", "--version"],
238
+ check=True,
239
+ capture_output=True,
240
+ text=True,
241
+ )
242
+ except (subprocess.CalledProcessError, FileNotFoundError):
243
+ raise DockerError(
244
+ "Docker is not available. Please install Docker and ensure it's running.\n"
245
+ "Visit https://docs.docker.com/get-docker/ for installation instructions."
246
+ )
247
+
248
+ def _get_image_id(self, image_tag: str) -> str:
249
+ """Get the full image ID for a given tag.
250
+
251
+ Args:
252
+ image_tag: Docker image tag (e.g., "tt-env-builder")
253
+
254
+ Returns:
255
+ Full image ID (e.g., "sha256:abc123def456...")
256
+
257
+ Raises:
258
+ DockerError: If cannot inspect image
259
+ """
260
+ try:
261
+ result = subprocess.run(
262
+ ["docker", "inspect", "--format={{.Id}}", image_tag],
263
+ check=True,
264
+ capture_output=True,
265
+ text=True,
266
+ )
267
+ image_id = result.stdout.strip()
268
+ return image_id
269
+ except subprocess.CalledProcessError as e:
270
+ raise DockerError(f"Failed to inspect image {image_tag}: {e.stderr}")
271
+
272
+
273
+ def is_docker_environment(env: Environment) -> bool:
274
+ """Check if environment is Docker-based.
275
+
276
+ Args:
277
+ env: Environment to check
278
+
279
+ Returns:
280
+ True if environment has a dockerfile field, False otherwise
281
+ """
282
+ return bool(env.dockerfile)
283
+
284
+
285
+ def resolve_container_working_dir(
286
+ env_working_dir: str, task_working_dir: str
287
+ ) -> str:
288
+ """Resolve working directory inside container.
289
+
290
+ Combines environment's working_dir with task's working_dir:
291
+ - If task specifies working_dir: container_dir = env_working_dir / task_working_dir
292
+ - If task doesn't specify: container_dir = env_working_dir
293
+ - If neither specify: container_dir = "/" (Docker default)
294
+
295
+ Args:
296
+ env_working_dir: Working directory from environment definition
297
+ task_working_dir: Working directory from task definition
298
+
299
+ Returns:
300
+ Resolved working directory path
301
+ """
302
+ if not env_working_dir and not task_working_dir:
303
+ return "/"
304
+
305
+ if not task_working_dir:
306
+ return env_working_dir
307
+
308
+ # Combine paths
309
+ if env_working_dir:
310
+ # Join paths using POSIX separator (works inside Linux containers)
311
+ return f"{env_working_dir.rstrip('/')}/{task_working_dir.lstrip('/')}"
312
+ else:
313
+ return f"/{task_working_dir.lstrip('/')}"
314
+
315
+
316
+ def parse_dockerignore(dockerignore_path: Path) -> "pathspec.PathSpec | None":
317
+ """Parse .dockerignore file into pathspec matcher.
318
+
319
+ Args:
320
+ dockerignore_path: Path to .dockerignore file
321
+
322
+ Returns:
323
+ PathSpec object for matching, or None if file doesn't exist or pathspec not available
324
+ """
325
+ if pathspec is None:
326
+ # pathspec library not available - can't parse .dockerignore
327
+ return None
328
+
329
+ if not dockerignore_path.exists():
330
+ return pathspec.PathSpec([]) # Empty matcher
331
+
332
+ try:
333
+ with open(dockerignore_path, "r") as f:
334
+ spec = pathspec.PathSpec.from_lines("gitwildmatch", f)
335
+ return spec
336
+ except Exception:
337
+ # Invalid patterns - return empty matcher rather than failing
338
+ return pathspec.PathSpec([])
339
+
340
+
341
+ def context_changed_since(
342
+ context_path: Path,
343
+ dockerignore_path: Path | None,
344
+ last_run_time: float,
345
+ ) -> bool:
346
+ """Check if any file in Docker build context has changed since last run.
347
+
348
+ Uses early-exit optimization: stops on first changed file found.
349
+
350
+ Args:
351
+ context_path: Path to Docker build context directory
352
+ dockerignore_path: Optional path to .dockerignore file
353
+ last_run_time: Unix timestamp of last task run
354
+
355
+ Returns:
356
+ True if any file changed, False otherwise
357
+ """
358
+ # Parse .dockerignore
359
+ dockerignore_spec = None
360
+ if dockerignore_path:
361
+ dockerignore_spec = parse_dockerignore(dockerignore_path)
362
+
363
+ # Walk context directory
364
+ for file_path in context_path.rglob("*"):
365
+ if not file_path.is_file():
366
+ continue
367
+
368
+ # Check if file matches .dockerignore patterns
369
+ if dockerignore_spec:
370
+ try:
371
+ relative_path = file_path.relative_to(context_path)
372
+ if dockerignore_spec.match_file(str(relative_path)):
373
+ continue # Skip ignored files
374
+ except ValueError:
375
+ # File not relative to context (shouldn't happen with rglob)
376
+ continue
377
+
378
+ # Check if file changed (early exit)
379
+ try:
380
+ if file_path.stat().st_mtime > last_run_time:
381
+ return True # Found a changed file
382
+ except (OSError, FileNotFoundError):
383
+ # File might have been deleted - consider it changed
384
+ return True
385
+
386
+ return False # No changes found
387
+
388
+
389
+ def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]:
390
+ """Extract image references from FROM lines in Dockerfile.
391
+
392
+ Args:
393
+ dockerfile_content: Content of Dockerfile
394
+
395
+ Returns:
396
+ List of (image_reference, digest) tuples where digest may be None for unpinned images
397
+ Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
398
+ """
399
+ # Regex pattern to match FROM lines
400
+ # Handles: FROM [--platform=...] image[:tag][@digest] [AS alias]
401
+ from_pattern = re.compile(
402
+ r"^\s*FROM\s+" # FROM keyword
403
+ r"(?:--platform=[^\s]+\s+)?" # Optional platform flag
404
+ r"([^\s@]+)" # Image name (possibly with :tag)
405
+ r"(?:@(sha256:[a-f0-9]+))?" # Optional @digest
406
+ r"(?:\s+AS\s+\w+)?" # Optional AS alias
407
+ r"\s*$",
408
+ re.MULTILINE | re.IGNORECASE,
409
+ )
410
+
411
+ matches = from_pattern.findall(dockerfile_content)
412
+ return [(image, digest if digest else None) for image, digest in matches]
413
+
414
+
415
+ def check_unpinned_images(dockerfile_content: str) -> list[str]:
416
+ """Check for unpinned base images in Dockerfile.
417
+
418
+ Args:
419
+ dockerfile_content: Content of Dockerfile
420
+
421
+ Returns:
422
+ List of unpinned image references (images without @sha256:... digests)
423
+ """
424
+ images = extract_from_images(dockerfile_content)
425
+ return [image for image, digest in images if digest is None]
426
+
427
+
428
+ def parse_base_image_digests(dockerfile_content: str) -> list[str]:
429
+ """Parse pinned base image digests from Dockerfile.
430
+
431
+ Args:
432
+ dockerfile_content: Content of Dockerfile
433
+
434
+ Returns:
435
+ List of digests (e.g., ["sha256:abc123...", "sha256:def456..."])
436
+ """
437
+ images = extract_from_images(dockerfile_content)
438
+ return [digest for _image, digest in images if digest is not None]