tasktree 0.0.20__py3-none-any.whl → 0.0.22__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
@@ -14,7 +21,12 @@ from rich.tree import Tree
14
21
 
15
22
  from tasktree import __version__
16
23
  from tasktree.executor import Executor
17
- from tasktree.graph import build_dependency_tree, resolve_execution_order, resolve_dependency_output_references, resolve_self_references
24
+ from tasktree.graph import (
25
+ build_dependency_tree,
26
+ resolve_execution_order,
27
+ resolve_dependency_output_references,
28
+ resolve_self_references,
29
+ )
18
30
  from tasktree.hasher import hash_task, hash_args
19
31
  from tasktree.parser import Recipe, find_recipe_file, parse_arg_spec, parse_recipe
20
32
  from tasktree.state import StateManager
@@ -29,10 +41,12 @@ console = Console()
29
41
 
30
42
 
31
43
  def _supports_unicode() -> bool:
32
- """Check if the terminal supports Unicode characters.
44
+ """
45
+ Check if the terminal supports Unicode characters.
33
46
 
34
47
  Returns:
35
- True if terminal supports UTF-8, False otherwise
48
+ True if terminal supports UTF-8, False otherwise
49
+ @athena: 68f62a942a95
36
50
  """
37
51
  # Hard stop: classic Windows console (conhost)
38
52
  if os.name == "nt" and "WT_SESSION" not in os.environ:
@@ -51,37 +65,43 @@ def _supports_unicode() -> bool:
51
65
 
52
66
 
53
67
  def get_action_success_string() -> str:
54
- """Get the appropriate success symbol based on terminal capabilities.
68
+ """
69
+ Get the appropriate success symbol based on terminal capabilities.
55
70
 
56
71
  Returns:
57
- Unicode tick symbol (✓) if terminal supports UTF-8, otherwise "[ OK ]"
72
+ Unicode tick symbol (✓) if terminal supports UTF-8, otherwise "[ OK ]"
73
+ @athena: 39d9966ee6c8
58
74
  """
59
75
  return "✓" if _supports_unicode() else "[ OK ]"
60
76
 
61
77
 
62
78
  def get_action_failure_string() -> str:
63
- """Get the appropriate failure symbol based on terminal capabilities.
79
+ """
80
+ Get the appropriate failure symbol based on terminal capabilities.
64
81
 
65
82
  Returns:
66
- Unicode cross symbol (✗) if terminal supports UTF-8, otherwise "[ FAIL ]"
83
+ Unicode cross symbol (✗) if terminal supports UTF-8, otherwise "[ FAIL ]"
84
+ @athena: 5dd1111f8d74
67
85
  """
68
86
  return "✗" if _supports_unicode() else "[ FAIL ]"
69
87
 
70
88
 
71
89
  def _format_task_arguments(arg_specs: list[str | dict]) -> str:
72
- """Format task arguments for display in list output.
90
+ """
91
+ Format task arguments for display in list output.
73
92
 
74
93
  Args:
75
- arg_specs: List of argument specifications from task definition (strings or dicts)
94
+ arg_specs: List of argument specifications from task definition (strings or dicts)
76
95
 
77
96
  Returns:
78
- Formatted string showing arguments with types and defaults
97
+ Formatted string showing arguments with types and defaults
79
98
 
80
99
  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]"
100
+ ["mode", "target"] -> "mode:str target:str"
101
+ ["mode=debug", "target=x86_64"] -> "mode:str [=debug] target:str [=x86_64]"
102
+ ["port:int", "debug:bool=false"] -> "port:int debug:bool [=false]"
103
+ [{"timeout": {"type": "int", "default": 30}}] -> "timeout:int [=30]"
104
+ @athena: fc3d6da90aeb
85
105
  """
86
106
  if not arg_specs:
87
107
  return ""
@@ -104,10 +124,15 @@ def _format_task_arguments(arg_specs: list[str | dict]) -> str:
104
124
 
105
125
 
106
126
  def _list_tasks(tasks_file: Optional[str] = None):
107
- """List all available tasks with descriptions."""
127
+ """
128
+ List all available tasks with descriptions.
129
+ @athena: 778f231737a1
130
+ """
108
131
  recipe = _get_recipe(tasks_file)
109
132
  if recipe is None:
110
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
133
+ console.print(
134
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
135
+ )
111
136
  raise typer.Exit(1)
112
137
 
113
138
  # Calculate maximum task name length for fixed-width column (only visible tasks)
@@ -116,13 +141,17 @@ def _list_tasks(tasks_file: Optional[str] = None):
116
141
  task = recipe.get_task(name)
117
142
  if task and not task.private:
118
143
  visible_task_names.append(name)
119
- max_task_name_len = max(len(name) for name in visible_task_names) if visible_task_names else 0
144
+ max_task_name_len = (
145
+ max(len(name) for name in visible_task_names) if visible_task_names else 0
146
+ )
120
147
 
121
148
  # Create borderless table with three columns
122
149
  table = Table(show_edge=False, show_header=False, box=None, padding=(0, 2))
123
150
 
124
- # Command column: fixed width to accommodate longest task name
125
- table.add_column("Command", style="bold cyan", no_wrap=True, width=max_task_name_len)
151
+ # Command column: fixed width to accommodate the longest task name
152
+ table.add_column(
153
+ "Command", style="bold cyan", no_wrap=True, width=max_task_name_len
154
+ )
126
155
 
127
156
  # Arguments column: allow wrapping with sensible max width
128
157
  table.add_column("Arguments", style="white", max_width=60)
@@ -144,11 +173,16 @@ def _list_tasks(tasks_file: Optional[str] = None):
144
173
 
145
174
 
146
175
  def _show_task(task_name: str, tasks_file: Optional[str] = None):
147
- """Show task definition with syntax highlighting."""
176
+ """
177
+ Show task definition with syntax highlighting.
178
+ @athena: 79ae3e330662
179
+ """
148
180
  # Pass task_name as root_task for lazy variable evaluation
149
181
  recipe = _get_recipe(tasks_file, root_task=task_name)
150
182
  if recipe is None:
151
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
183
+ console.print(
184
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
185
+ )
152
186
  raise typer.Exit(1)
153
187
 
154
188
  task = recipe.get_task(task_name)
@@ -181,9 +215,9 @@ def _show_task(task_name: str, tasks_file: Optional[str] = None):
181
215
  # Configure YAML dumper to use literal block style for multiline strings
182
216
  def literal_presenter(dumper, data):
183
217
  """Use literal block style (|) for strings containing newlines."""
184
- if '\n' in data:
185
- return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
186
- return dumper.represent_scalar('tag:yaml.org,2002:str', data)
218
+ if "\n" in data:
219
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
220
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data)
187
221
 
188
222
  yaml.add_representer(str, literal_presenter)
189
223
 
@@ -194,11 +228,16 @@ def _show_task(task_name: str, tasks_file: Optional[str] = None):
194
228
 
195
229
 
196
230
  def _show_tree(task_name: str, tasks_file: Optional[str] = None):
197
- """Show dependency tree structure."""
231
+ """
232
+ Show dependency tree structure.
233
+ @athena: a906cef99324
234
+ """
198
235
  # Pass task_name as root_task for lazy variable evaluation
199
236
  recipe = _get_recipe(tasks_file, root_task=task_name)
200
237
  if recipe is None:
201
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
238
+ console.print(
239
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
240
+ )
202
241
  raise typer.Exit(1)
203
242
 
204
243
  task = recipe.get_task(task_name)
@@ -219,7 +258,10 @@ def _show_tree(task_name: str, tasks_file: Optional[str] = None):
219
258
 
220
259
 
221
260
  def _init_recipe():
222
- """Create a blank recipe file with commented examples."""
261
+ """
262
+ Create a blank recipe file with commented examples.
263
+ @athena: 189726c9b6c0
264
+ """
223
265
  recipe_path = Path("tasktree.yaml")
224
266
  if recipe_path.exists():
225
267
  console.print("[red]tasktree.yaml already exists[/red]")
@@ -260,7 +302,10 @@ tasks:
260
302
 
261
303
 
262
304
  def _version_callback(value: bool):
263
- """Show version and exit."""
305
+ """
306
+ Show version and exit.
307
+ @athena: abaed96ac23b
308
+ """
264
309
  if value:
265
310
  console.print(f"task-tree version {__version__}")
266
311
  raise typer.Exit()
@@ -277,10 +322,18 @@ def main(
277
322
  is_eager=True,
278
323
  help="Show version and exit",
279
324
  ),
280
- list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
281
- show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
282
- tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
283
- tasks_file: Optional[str] = typer.Option(None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"),
325
+ list_opt: Optional[bool] = typer.Option(
326
+ None, "--list", "-l", help="List all available tasks"
327
+ ),
328
+ show: Optional[str] = typer.Option(
329
+ None, "--show", "-s", help="Show task definition"
330
+ ),
331
+ tree: Optional[str] = typer.Option(
332
+ None, "--tree", "-t", help="Show dependency tree"
333
+ ),
334
+ tasks_file: Optional[str] = typer.Option(
335
+ None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"
336
+ ),
284
337
  init: Optional[bool] = typer.Option(
285
338
  None, "--init", "-i", help="Create a blank tasktree.yaml"
286
339
  ),
@@ -297,7 +350,10 @@ def main(
297
350
  None, "--force", "-f", help="Force re-run all tasks (ignore freshness)"
298
351
  ),
299
352
  only: Optional[bool] = typer.Option(
300
- None, "--only", "-o", help="Run only the specified task, skip dependencies (implies --force)"
353
+ None,
354
+ "--only",
355
+ "-o",
356
+ help="Run only the specified task, skip dependencies (implies --force)",
301
357
  ),
302
358
  env: Optional[str] = typer.Option(
303
359
  None, "--env", "-e", help="Override environment for all tasks"
@@ -306,17 +362,19 @@ def main(
306
362
  None, help="Task name and arguments"
307
363
  ),
308
364
  ):
309
- """Task Tree - A task automation tool with incremental execution.
365
+ """
366
+ Task Tree - A task automation tool with incremental execution.
310
367
 
311
368
  Run tasks defined in tasktree.yaml with dependency tracking
312
369
  and incremental execution.
313
370
 
314
371
  Examples:
315
372
 
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'
373
+ tt build # Run the 'build' task
374
+ tt deploy prod region=us-1 # Run 'deploy' with arguments
375
+ tt --list # List all tasks
376
+ tt --tree test # Show dependency tree for 'test'
377
+ @athena: f76c75c12d10
320
378
  """
321
379
 
322
380
  if list_opt:
@@ -342,11 +400,19 @@ def main(
342
400
  if task_args:
343
401
  # --only implies --force
344
402
  force_execution = force or only or False
345
- _execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env, tasks_file=tasks_file)
403
+ _execute_dynamic_task(
404
+ task_args,
405
+ force=force_execution,
406
+ only=only or False,
407
+ env=env,
408
+ tasks_file=tasks_file,
409
+ )
346
410
  else:
347
411
  recipe = _get_recipe(tasks_file)
348
412
  if recipe is None:
349
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
413
+ console.print(
414
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
415
+ )
350
416
  console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
351
417
  raise typer.Exit(1)
352
418
 
@@ -360,7 +426,10 @@ def main(
360
426
 
361
427
 
362
428
  def _clean_state(tasks_file: Optional[str] = None) -> None:
363
- """Remove the .tasktree-state file to reset task execution state."""
429
+ """
430
+ Remove the .tasktree-state file to reset task execution state.
431
+ @athena: a0ddf4b333d4
432
+ """
364
433
  if tasks_file:
365
434
  recipe_path = Path(tasks_file)
366
435
  if not recipe_path.exists():
@@ -378,19 +447,25 @@ def _clean_state(tasks_file: Optional[str] = None) -> None:
378
447
 
379
448
  if state_path.exists():
380
449
  state_path.unlink()
381
- console.print(f"[green]{get_action_success_string()} Removed {state_path}[/green]")
450
+ console.print(
451
+ f"[green]{get_action_success_string()} Removed {state_path}[/green]"
452
+ )
382
453
  console.print("All tasks will run fresh on next execution")
383
454
  else:
384
455
  console.print(f"[yellow]No state file found at {state_path}[/yellow]")
385
456
 
386
457
 
387
- def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = None) -> Optional[Recipe]:
388
- """Get parsed recipe or None if not found.
458
+ def _get_recipe(
459
+ recipe_file: Optional[str] = None, root_task: Optional[str] = None
460
+ ) -> Optional[Recipe]:
461
+ """
462
+ Get parsed recipe or None if not found.
389
463
 
390
464
  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).
465
+ recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
466
+ root_task: Optional root task for lazy variable evaluation. If provided, only variables
467
+ reachable from this task will be evaluated (performance optimization).
468
+ @athena: 0ee00c67df25
394
469
  """
395
470
  if recipe_file:
396
471
  recipe_path = Path(recipe_file)
@@ -418,7 +493,25 @@ def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = No
418
493
  raise typer.Exit(1)
419
494
 
420
495
 
421
- def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None, tasks_file: Optional[str] = None) -> None:
496
+ def _execute_dynamic_task(
497
+ args: list[str],
498
+ force: bool = False,
499
+ only: bool = False,
500
+ env: Optional[str] = None,
501
+ tasks_file: Optional[str] = None,
502
+ ) -> None:
503
+ """
504
+ Execute a task with its dependencies and handle argument parsing.
505
+
506
+ Args:
507
+ args: Task name followed by optional task arguments
508
+ force: Force re-execution even if task is up-to-date
509
+ only: Execute only the specified task, skip dependencies
510
+ env: Override environment for task execution
511
+ tasks_file: Path to recipe file (optional)
512
+
513
+ @athena: 207f7635a60d
514
+ """
422
515
  if not args:
423
516
  return
424
517
 
@@ -428,7 +521,9 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
428
521
  # Pass task_name as root_task for lazy variable evaluation
429
522
  recipe = _get_recipe(tasks_file, root_task=task_name)
430
523
  if recipe is None:
431
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
524
+ console.print(
525
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
526
+ )
432
527
  raise typer.Exit(1)
433
528
 
434
529
  # Apply global environment override if provided
@@ -488,7 +583,7 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
488
583
  task.working_dir,
489
584
  task.args,
490
585
  executor._get_effective_env_name(task),
491
- task.deps
586
+ task.deps,
492
587
  )
493
588
 
494
589
  # If task has arguments, append args hash to create unique cache key
@@ -504,16 +599,35 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
504
599
  state.save()
505
600
  try:
506
601
  executor.execute_task(task_name, args_dict, force=force, only=only)
507
- console.print(f"[green]{get_action_success_string()} Task '{task_name}' completed successfully[/green]")
602
+ console.print(
603
+ f"[green]{get_action_success_string()} Task '{task_name}' completed successfully[/green]"
604
+ )
508
605
  except Exception as e:
509
- console.print(f"[red]{get_action_failure_string()} Task '{task_name}' failed: {e}[/red]")
606
+ console.print(
607
+ f"[red]{get_action_failure_string()} Task '{task_name}' failed: {e}[/red]"
608
+ )
510
609
  raise typer.Exit(1)
511
610
 
512
611
 
513
612
  def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, Any]:
613
+ """
614
+ Parse and validate task arguments from command line values.
615
+
616
+ Args:
617
+ arg_specs: Task argument specifications with types and defaults
618
+ arg_values: Raw argument values from command line (positional or named)
619
+
620
+ Returns:
621
+ Dictionary mapping argument names to typed, validated values
622
+
623
+ Raises:
624
+ typer.Exit: If arguments are invalid, missing, or unknown
625
+
626
+ @athena: 2072a35f9d11
627
+ """
514
628
  if not arg_specs:
515
629
  if arg_values:
516
- console.print(f"[red]Task does not accept arguments[/red]")
630
+ console.print("[red]Task does not accept arguments[/red]")
517
631
  raise typer.Exit(1)
518
632
  return {}
519
633
 
@@ -537,7 +651,7 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
537
651
  else:
538
652
  # Positional argument
539
653
  if positional_index >= len(parsed_specs):
540
- console.print(f"[red]Too many arguments[/red]")
654
+ console.print("[red]Too many arguments[/red]")
541
655
  raise typer.Exit(1)
542
656
  spec = parsed_specs[positional_index]
543
657
  arg_value = value_str
@@ -545,13 +659,19 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
545
659
 
546
660
  # Convert value to appropriate type (exported args are always strings)
547
661
  try:
548
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
662
+ click_type = get_click_type(
663
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
664
+ )
549
665
  converted_value = click_type.convert(arg_value, None, None)
550
666
 
551
667
  # Validate choices after type conversion
552
668
  if spec.choices is not None and converted_value not in spec.choices:
553
- console.print(f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]")
554
- console.print(f"Valid choices: {', '.join(repr(c) for c in spec.choices)}")
669
+ console.print(
670
+ f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]"
671
+ )
672
+ console.print(
673
+ f"Valid choices: {', '.join(repr(c) for c in spec.choices)}"
674
+ )
555
675
  raise typer.Exit(1)
556
676
 
557
677
  args_dict[spec.name] = converted_value
@@ -566,10 +686,14 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
566
686
  if spec.name not in args_dict:
567
687
  if spec.default is not None:
568
688
  try:
569
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
689
+ click_type = get_click_type(
690
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
691
+ )
570
692
  args_dict[spec.name] = click_type.convert(spec.default, None, None)
571
693
  except Exception as e:
572
- console.print(f"[red]Invalid default value for {spec.name}: {e}[/red]")
694
+ console.print(
695
+ f"[red]Invalid default value for {spec.name}: {e}[/red]"
696
+ )
573
697
  raise typer.Exit(1)
574
698
  else:
575
699
  console.print(f"[red]Missing required argument: {spec.name}[/red]")
@@ -579,6 +703,17 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
579
703
 
580
704
 
581
705
  def _build_rich_tree(dep_tree: dict) -> Tree:
706
+ """
707
+ Build a Rich Tree visualization from a dependency tree structure.
708
+
709
+ Args:
710
+ dep_tree: Nested dictionary representing task dependencies
711
+
712
+ Returns:
713
+ Rich Tree object for terminal display
714
+
715
+ @athena: 62472c8ca729
716
+ """
582
717
  task_name = dep_tree["name"]
583
718
  tree = Tree(task_name)
584
719
 
@@ -591,7 +726,10 @@ def _build_rich_tree(dep_tree: dict) -> Tree:
591
726
 
592
727
 
593
728
  def cli():
594
- """Entry point for the CLI."""
729
+ """
730
+ Entry point for the CLI.
731
+ @athena: 3b3cccd1ff6f
732
+ """
595
733
  app()
596
734
 
597
735