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 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 or tt.yaml)[/red]")
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 or tt.yaml)[/red]")
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 or tt.yaml)[/red]")
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 or tt.yaml)[/red]")
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
- recipe_path = find_recipe_file()
251
- if recipe_path is None:
252
- console.print("[yellow]No recipe file found[/yellow]")
253
- console.print("State file location depends on recipe file location")
254
- raise typer.Exit(1)
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
- recipe_path = find_recipe_file()
270
- if recipe_path is None:
271
- return None
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 or tt.yaml)[/red]")
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]