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 CHANGED
@@ -1,4 +1,7 @@
1
- """Task Tree - A task automation tool with intelligent incremental execution."""
1
+ """
2
+ Task Tree - A task automation tool with intelligent incremental execution.
3
+ @athena: 1b4a97c4bc42
4
+ """
2
5
 
3
6
  try:
4
7
  from importlib.metadata import version
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
- """Check if the terminal supports Unicode characters.
39
+ """
40
+ Check if the terminal supports Unicode characters.
33
41
 
34
42
  Returns:
35
- True if terminal supports UTF-8, False otherwise
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
- """Get the appropriate success symbol based on terminal capabilities.
63
+ """
64
+ Get the appropriate success symbol based on terminal capabilities.
55
65
 
56
66
  Returns:
57
- Unicode tick symbol (✓) if terminal supports UTF-8, otherwise "[ OK ]"
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
- """Get the appropriate failure symbol based on terminal capabilities.
74
+ """
75
+ Get the appropriate failure symbol based on terminal capabilities.
64
76
 
65
77
  Returns:
66
- Unicode cross symbol (✗) if terminal supports UTF-8, otherwise "[ FAIL ]"
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
- """Format task arguments for display in list output.
85
+ """
86
+ Format task arguments for display in list output.
73
87
 
74
88
  Args:
75
- arg_specs: List of argument specifications from task definition (strings or dicts)
89
+ arg_specs: List of argument specifications from task definition (strings or dicts)
76
90
 
77
91
  Returns:
78
- Formatted string showing arguments with types and defaults
92
+ Formatted string showing arguments with types and defaults
79
93
 
80
94
  Examples:
81
- ["mode", "target"] -> "mode:str target:str"
82
- ["mode=debug", "target=x86_64"] -> "mode:str [=debug] target:str [=x86_64]"
83
- ["port:int", "debug:bool=false"] -> "port:int debug:bool [=false]"
84
- [{"timeout": {"type": "int", "default": 30}}] -> "timeout:int [=30]"
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
- """List all available tasks with descriptions."""
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
- """Show task definition with syntax highlighting."""
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
- """Show dependency tree structure."""
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
- """Create a blank recipe file with commented examples."""
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
- """Show version and exit."""
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
- """Task Tree - A task automation tool with incremental execution.
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
- tt build # Run the 'build' task
317
- tt deploy prod region=us-1 # Run 'deploy' with arguments
318
- tt --list # List all tasks
319
- tt --tree test # Show dependency tree for 'test'
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
- """Remove the .tasktree-state file to reset task execution state."""
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
- """Get parsed recipe or None if not found.
423
+ """
424
+ Get parsed recipe or None if not found.
389
425
 
390
426
  Args:
391
- recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
392
- root_task: Optional root task for lazy variable evaluation. If provided, only variables
393
- reachable from this task will be evaluated (performance optimization).
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
- """Entry point for the CLI."""
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
- """Raised when Docker operations fail."""
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
- """Manages Docker image building and container execution."""
35
+ """
36
+ Manages Docker image building and container execution.
37
+ @athena: ef3cc3d7bcbe
38
+ """
33
39
 
34
40
  def __init__(self, project_root: Path):
35
- """Initialize Docker manager.
41
+ """
42
+ Initialize Docker manager.
36
43
 
37
44
  Args:
38
- project_root: Root directory of the project (where tasktree.yaml is located)
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
- """Check if --user flag should be added to docker run.
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
- True if --user flag should be added, False otherwise
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
- """Build Docker image if not already built this invocation.
70
+ """
71
+ Build Docker image if not already built this invocation.
61
72
 
62
73
  Args:
63
- env: Environment definition with dockerfile and context
74
+ env: Environment definition with dockerfile and context
64
75
 
65
76
  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..."
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
- DockerError: If docker command not available or build fails
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
- """Execute command inside Docker container.
147
+ """
148
+ Execute command inside Docker container.
136
149
 
137
150
  Args:
138
- env: Environment definition
139
- cmd: Command to execute
140
- working_dir: Host working directory (for resolving relative volume paths)
141
- container_working_dir: Working directory inside container
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
- CompletedProcess from subprocess.run
157
+ CompletedProcess from subprocess.run
145
158
 
146
159
  Raises:
147
- DockerError: If docker run fails
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
- """Resolve volume mount specification.
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
- volume: Volume specification (e.g., "./src:/workspace/src" or "~/.cargo:/root/.cargo")
226
+ volume: Volume specification (e.g., "./src:/workspace/src" or "~/.cargo:/root/.cargo")
212
227
 
213
228
  Returns:
214
- Resolved volume specification with absolute host path
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
- """Check if docker command is available.
254
+ """
255
+ Check if docker command is available.
239
256
 
240
257
  Raises:
241
- DockerError: If docker is not available
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
- """Get the full image ID for a given tag.
275
+ """
276
+ Get the full image ID for a given tag.
258
277
 
259
278
  Args:
260
- image_tag: Docker image tag (e.g., "tt-env-builder")
279
+ image_tag: Docker image tag (e.g., "tt-env-builder")
261
280
 
262
281
  Returns:
263
- Full image ID (e.g., "sha256:abc123def456...")
282
+ Full image ID (e.g., "sha256:abc123def456...")
264
283
 
265
284
  Raises:
266
- DockerError: If cannot inspect image
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
- """Check if environment is Docker-based.
302
+ """
303
+ Check if environment is Docker-based.
283
304
 
284
305
  Args:
285
- env: Environment to check
306
+ env: Environment to check
286
307
 
287
308
  Returns:
288
- True if environment has a dockerfile field, False otherwise
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
- """Resolve working directory inside container.
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
- env_working_dir: Working directory from environment definition
305
- task_working_dir: Working directory from task definition
327
+ env_working_dir: Working directory from environment definition
328
+ task_working_dir: Working directory from task definition
306
329
 
307
330
  Returns:
308
- Resolved working directory path
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
- """Parse .dockerignore file into pathspec matcher.
349
+ """
350
+ Parse .dockerignore file into pathspec matcher.
326
351
 
327
352
  Args:
328
- dockerignore_path: Path to .dockerignore file
353
+ dockerignore_path: Path to .dockerignore file
329
354
 
330
355
  Returns:
331
- PathSpec object for matching, or None if file doesn't exist or pathspec not available
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
- """Check if any file in Docker build context has changed since last run.
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
- context_path: Path to Docker build context directory
360
- dockerignore_path: Optional path to .dockerignore file
361
- last_run_time: Unix timestamp of last task run
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
- True if any file changed, False otherwise
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
- """Extract image references from FROM lines in Dockerfile.
426
+ """
427
+ Extract image references from FROM lines in Dockerfile.
399
428
 
400
429
  Args:
401
- dockerfile_content: Content of Dockerfile
430
+ dockerfile_content: Content of Dockerfile
402
431
 
403
432
  Returns:
404
- List of (image_reference, digest) tuples where digest may be None for unpinned images
405
- Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
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
- """Check for unpinned base images in Dockerfile.
454
+ """
455
+ Check for unpinned base images in Dockerfile.
425
456
 
426
457
  Args:
427
- dockerfile_content: Content of Dockerfile
458
+ dockerfile_content: Content of Dockerfile
428
459
 
429
460
  Returns:
430
- List of unpinned image references (images without @sha256:... digests)
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
- """Parse pinned base image digests from Dockerfile.
469
+ """
470
+ Parse pinned base image digests from Dockerfile.
438
471
 
439
472
  Args:
440
- dockerfile_content: Content of Dockerfile
473
+ dockerfile_content: Content of Dockerfile
441
474
 
442
475
  Returns:
443
- List of digests (e.g., ["sha256:abc123...", "sha256:def456..."])
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]