tasktree 0.0.1__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 ADDED
@@ -0,0 +1,42 @@
1
+ """Task Tree - A task automation tool with intelligent incremental execution."""
2
+
3
+ try:
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("tasktree")
7
+ except Exception:
8
+ __version__ = "0.0.0.dev0+local" # Fallback for development
9
+
10
+ from tasktree.executor import Executor, ExecutionError, TaskStatus
11
+ from tasktree.graph import (
12
+ CycleError,
13
+ TaskNotFoundError,
14
+ build_dependency_tree,
15
+ get_implicit_inputs,
16
+ resolve_execution_order,
17
+ )
18
+ from tasktree.hasher import hash_args, hash_task, make_cache_key
19
+ from tasktree.parser import Recipe, Task, find_recipe_file, parse_arg_spec, parse_recipe
20
+ from tasktree.state import StateManager, TaskState
21
+
22
+ __all__ = [
23
+ "__version__",
24
+ "Executor",
25
+ "ExecutionError",
26
+ "TaskStatus",
27
+ "CycleError",
28
+ "TaskNotFoundError",
29
+ "build_dependency_tree",
30
+ "get_implicit_inputs",
31
+ "resolve_execution_order",
32
+ "hash_args",
33
+ "hash_task",
34
+ "make_cache_key",
35
+ "Recipe",
36
+ "Task",
37
+ "find_recipe_file",
38
+ "parse_arg_spec",
39
+ "parse_recipe",
40
+ "StateManager",
41
+ "TaskState",
42
+ ]
tasktree/cli.py ADDED
@@ -0,0 +1,502 @@
1
+ """Command-line interface for Task Tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, List, Optional
8
+
9
+ import typer
10
+ import yaml
11
+ from rich.console import Console
12
+ from rich.syntax import Syntax
13
+ from rich.table import Table
14
+ from rich.tree import Tree
15
+
16
+ from tasktree import __version__
17
+ from tasktree.executor import Executor
18
+ from tasktree.graph import build_dependency_tree
19
+ from tasktree.hasher import hash_task
20
+ from tasktree.parser import Recipe, find_recipe_file, parse_arg_spec, parse_recipe
21
+ from tasktree.state import StateManager
22
+ from tasktree.types import get_click_type
23
+
24
+ app = typer.Typer(
25
+ help="Task Tree - A task automation tool with intelligent incremental execution",
26
+ add_completion=False,
27
+ no_args_is_help=True,
28
+ )
29
+ console = Console()
30
+
31
+
32
+ def _list_tasks():
33
+ """List all available tasks with descriptions."""
34
+ recipe = _get_recipe()
35
+ if recipe is None:
36
+ console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
37
+ raise typer.Exit(1)
38
+
39
+ table = Table(title="Available Tasks")
40
+ table.add_column("Task", style="cyan", no_wrap=True)
41
+ table.add_column("Description", style="white")
42
+
43
+ for task_name in sorted(recipe.task_names()):
44
+ task = recipe.get_task(task_name)
45
+ desc = task.desc if task else ""
46
+ table.add_row(task_name, desc)
47
+
48
+ console.print(table)
49
+
50
+
51
+ def _show_task(task_name: str):
52
+ """Show task definition with syntax highlighting."""
53
+ recipe = _get_recipe()
54
+ if recipe is None:
55
+ console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
56
+ raise typer.Exit(1)
57
+
58
+ task = recipe.get_task(task_name)
59
+ if task is None:
60
+ console.print(f"[red]Task not found: {task_name}[/red]")
61
+ raise typer.Exit(1)
62
+
63
+ # Show source file info
64
+ console.print(f"[bold]Task: {task_name}[/bold]")
65
+ if task.source_file:
66
+ console.print(f"Source: {task.source_file}\n")
67
+
68
+ # Create YAML representation
69
+ task_yaml = {
70
+ task_name: {
71
+ "desc": task.desc,
72
+ "deps": task.deps,
73
+ "inputs": task.inputs,
74
+ "outputs": task.outputs,
75
+ "working_dir": task.working_dir,
76
+ "args": task.args,
77
+ "cmd": task.cmd,
78
+ }
79
+ }
80
+
81
+ # Remove empty fields for cleaner display
82
+ task_dict = task_yaml[task_name]
83
+ task_yaml[task_name] = {k: v for k, v in task_dict.items() if v}
84
+
85
+ # Format and highlight using Rich
86
+ yaml_str = yaml.dump(task_yaml, default_flow_style=False, sort_keys=False)
87
+ syntax = Syntax(yaml_str, "yaml", theme="ansi_light", line_numbers=False)
88
+ console.print(syntax)
89
+
90
+
91
+ def _show_tree(task_name: str):
92
+ """Show dependency tree with freshness indicators."""
93
+ recipe = _get_recipe()
94
+ if recipe is None:
95
+ console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
96
+ raise typer.Exit(1)
97
+
98
+ task = recipe.get_task(task_name)
99
+ if task is None:
100
+ console.print(f"[red]Task not found: {task_name}[/red]")
101
+ raise typer.Exit(1)
102
+
103
+ # Build dependency tree
104
+ try:
105
+ dep_tree = build_dependency_tree(recipe, task_name)
106
+ except Exception as e:
107
+ console.print(f"[red]Error building dependency tree: {e}[/red]")
108
+ raise typer.Exit(1)
109
+
110
+ # Get execution statuses
111
+ state = StateManager(recipe.project_root)
112
+ state.load()
113
+ executor = Executor(recipe, state)
114
+ statuses = executor.execute_task(task_name, dry_run=True)
115
+
116
+ # Build Rich tree
117
+ tree = _build_rich_tree(dep_tree, statuses)
118
+ console.print(tree)
119
+
120
+
121
+ def _dry_run(task_name: str):
122
+ """Show what would be executed without actually running."""
123
+ recipe = _get_recipe()
124
+ if recipe is None:
125
+ console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
126
+ raise typer.Exit(1)
127
+
128
+ task = recipe.get_task(task_name)
129
+ if task is None:
130
+ console.print(f"[red]Task not found: {task_name}[/red]")
131
+ raise typer.Exit(1)
132
+
133
+ # Get execution plan
134
+ state = StateManager(recipe.project_root)
135
+ state.load()
136
+ executor = Executor(recipe, state)
137
+ statuses = executor.execute_task(task_name, dry_run=True)
138
+
139
+ # Display plan
140
+ console.print(f"[bold]Execution plan for '{task_name}':[/bold]\n")
141
+
142
+ will_run = [name for name, status in statuses.items() if status.will_run]
143
+ will_skip = [name for name, status in statuses.items() if not status.will_run]
144
+
145
+ if will_run:
146
+ console.print(f"[yellow]Will execute ({len(will_run)} tasks):[/yellow]")
147
+ for i, name in enumerate(will_run, 1):
148
+ status = statuses[name]
149
+ console.print(f" {i}. [cyan]{name}[/cyan]")
150
+ console.print(f" - {status.reason}")
151
+ if status.changed_files:
152
+ console.print(f" - changed files: {', '.join(status.changed_files)}")
153
+ console.print()
154
+
155
+ if will_skip:
156
+ console.print(f"[green]Will skip ({len(will_skip)} tasks):[/green]")
157
+ for name in will_skip:
158
+ status = statuses[name]
159
+ last_run_str = f", last run {status.last_run}" if status.last_run else ""
160
+ console.print(f" - {name} (fresh{last_run_str})")
161
+
162
+
163
+ def _init_recipe():
164
+ """Create a blank recipe file with commented examples."""
165
+ recipe_path = Path("tasktree.yaml")
166
+ if recipe_path.exists():
167
+ console.print("[red]tasktree.yaml already exists[/red]")
168
+ raise typer.Exit(1)
169
+
170
+ template = """# Task Tree Recipe
171
+ # See https://github.com/kevinchannon/tasktree for documentation
172
+
173
+ # Example task definitions:
174
+
175
+ # build:
176
+ # desc: Compile the application
177
+ # outputs: [target/release/bin]
178
+ # cmd: cargo build --release
179
+
180
+ # test:
181
+ # desc: Run tests
182
+ # deps: [build]
183
+ # cmd: cargo test
184
+
185
+ # deploy:
186
+ # desc: Deploy to environment
187
+ # deps: [build]
188
+ # args: [environment, region=eu-west-1]
189
+ # cmd: |
190
+ # echo "Deploying to {{environment}} in {{region}}"
191
+ # ./deploy.sh {{environment}} {{region}}
192
+
193
+ # Uncomment and modify the examples above to define your tasks
194
+ """
195
+
196
+ recipe_path.write_text(template)
197
+ console.print(f"[green]Created {recipe_path}[/green]")
198
+ console.print("Edit the file to define your tasks")
199
+
200
+
201
+ def _version_callback(value: bool):
202
+ """Show version and exit."""
203
+ if value:
204
+ console.print(f"task-tree version {__version__}")
205
+ raise typer.Exit()
206
+
207
+
208
+ @app.callback(invoke_without_command=True)
209
+ def main(
210
+ ctx: typer.Context,
211
+ version: Optional[bool] = typer.Option(
212
+ None,
213
+ "--version",
214
+ "-v",
215
+ callback=_version_callback,
216
+ is_eager=True,
217
+ help="Show version and exit",
218
+ ),
219
+ list_tasks: Optional[bool] = typer.Option(
220
+ None, "--list", "-l", help="List all available tasks"
221
+ ),
222
+ show: Optional[str] = typer.Option(None, "--show", help="Show task definition"),
223
+ tree: Optional[str] = typer.Option(None, "--tree", help="Show dependency tree"),
224
+ dry_run: Optional[str] = typer.Option(
225
+ None, "--dry-run", help="Show execution plan without running"
226
+ ),
227
+ init: Optional[bool] = typer.Option(
228
+ None, "--init", help="Create a blank tasktree.yaml"
229
+ ),
230
+ clean: Optional[bool] = typer.Option(
231
+ None, "--clean", help="Remove state file (reset task cache)"
232
+ ),
233
+ clean_state: Optional[bool] = typer.Option(
234
+ None, "--clean-state", help="Remove state file (reset task cache)"
235
+ ),
236
+ reset: Optional[bool] = typer.Option(
237
+ None, "--reset", help="Remove state file (reset task cache)"
238
+ ),
239
+ task_args: Optional[List[str]] = typer.Argument(
240
+ None, help="Task name and arguments"
241
+ ),
242
+ ):
243
+ """Task Tree - A task automation tool with intelligent incremental execution.
244
+
245
+ Run tasks defined in tasktree.yaml with intelligent dependency tracking
246
+ and incremental execution.
247
+
248
+ Examples:
249
+
250
+ tt build # Run the 'build' task
251
+ tt deploy prod region=us-1 # Run 'deploy' with arguments
252
+ tt --list # List all tasks
253
+ tt --tree test # Show dependency tree for 'test'
254
+ """
255
+ # Handle list option
256
+ if list_tasks:
257
+ _list_tasks()
258
+ raise typer.Exit()
259
+
260
+ # Handle show option
261
+ if show:
262
+ _show_task(show)
263
+ raise typer.Exit()
264
+
265
+ # Handle tree option
266
+ if tree:
267
+ _show_tree(tree)
268
+ raise typer.Exit()
269
+
270
+ # Handle dry-run option
271
+ if dry_run:
272
+ _dry_run(dry_run)
273
+ raise typer.Exit()
274
+
275
+ # Handle init option
276
+ if init:
277
+ _init_recipe()
278
+ raise typer.Exit()
279
+
280
+ # Handle clean options (all three aliases)
281
+ if clean or clean_state or reset:
282
+ _clean_state()
283
+ raise typer.Exit()
284
+
285
+ # Handle task execution
286
+ if task_args:
287
+ _execute_dynamic_task(task_args)
288
+ else:
289
+ # No arguments - show available tasks
290
+ recipe = _get_recipe()
291
+ if recipe is None:
292
+ console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
293
+ console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
294
+ raise typer.Exit(1)
295
+
296
+ console.print("[bold]Available tasks:[/bold]")
297
+ for task_name in sorted(recipe.task_names()):
298
+ console.print(f" - {task_name}")
299
+ console.print("\nUse [cyan]tt --list[/cyan] for detailed information")
300
+ console.print("Use [cyan]tt <task-name>[/cyan] to run a task")
301
+
302
+
303
+ def _clean_state() -> None:
304
+ """Remove the .tasktree-state file to reset task execution state."""
305
+ recipe_path = find_recipe_file()
306
+ if recipe_path is None:
307
+ console.print("[yellow]No recipe file found[/yellow]")
308
+ console.print("State file location depends on recipe file location")
309
+ raise typer.Exit(1)
310
+
311
+ project_root = recipe_path.parent
312
+ state_path = project_root / ".tasktree-state"
313
+
314
+ if state_path.exists():
315
+ state_path.unlink()
316
+ console.print(f"[green]✓ Removed {state_path}[/green]")
317
+ console.print("All tasks will run fresh on next execution")
318
+ else:
319
+ console.print(f"[yellow]No state file found at {state_path}[/yellow]")
320
+
321
+
322
+ def _get_recipe() -> Recipe | None:
323
+ """Get parsed recipe or None if not found."""
324
+ recipe_path = find_recipe_file()
325
+ if recipe_path is None:
326
+ return None
327
+
328
+ try:
329
+ return parse_recipe(recipe_path)
330
+ except Exception as e:
331
+ console.print(f"[red]Error parsing recipe: {e}[/red]")
332
+ raise typer.Exit(1)
333
+
334
+
335
+ def _execute_dynamic_task(args: list[str]) -> None:
336
+ """Execute a task specified by name with arguments.
337
+
338
+ Args:
339
+ args: Command line arguments (task name and task arguments)
340
+ """
341
+ if not args:
342
+ return
343
+
344
+ task_name = args[0]
345
+ task_args = args[1:]
346
+
347
+ recipe = _get_recipe()
348
+ if recipe is None:
349
+ console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
350
+ raise typer.Exit(1)
351
+
352
+ task = recipe.get_task(task_name)
353
+ if task is None:
354
+ console.print(f"[red]Task not found: {task_name}[/red]")
355
+ console.print("\nAvailable tasks:")
356
+ for name in sorted(recipe.task_names()):
357
+ console.print(f" - {name}")
358
+ raise typer.Exit(1)
359
+
360
+ # Parse task arguments
361
+ args_dict = _parse_task_args(task.args, task_args)
362
+
363
+ # Prune state before execution
364
+ state = StateManager(recipe.project_root)
365
+ state.load()
366
+ valid_hashes = {
367
+ hash_task(t.cmd, t.outputs, t.working_dir, t.args)
368
+ for t in recipe.tasks.values()
369
+ }
370
+ state.prune(valid_hashes)
371
+ state.save()
372
+
373
+ # Execute task
374
+ executor = Executor(recipe, state)
375
+ try:
376
+ executor.execute_task(task_name, args_dict, dry_run=False)
377
+ console.print(f"[green]✓ Task '{task_name}' completed successfully[/green]")
378
+ except Exception as e:
379
+ console.print(f"[red]✗ Task '{task_name}' failed: {e}[/red]")
380
+ raise typer.Exit(1)
381
+
382
+
383
+ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, Any]:
384
+ """Parse command line arguments for a task.
385
+
386
+ Args:
387
+ arg_specs: Argument specifications from task definition
388
+ arg_values: Command line argument values
389
+
390
+ Returns:
391
+ Dictionary of argument names to values
392
+
393
+ Raises:
394
+ typer.Exit: If arguments are invalid
395
+ """
396
+ if not arg_specs:
397
+ if arg_values:
398
+ console.print(f"[red]Task does not accept arguments[/red]")
399
+ raise typer.Exit(1)
400
+ return {}
401
+
402
+ # Parse argument specifications
403
+ parsed_specs = []
404
+ for spec in arg_specs:
405
+ name, arg_type, default = parse_arg_spec(spec)
406
+ parsed_specs.append((name, arg_type, default))
407
+
408
+ # Build argument dictionary
409
+ args_dict = {}
410
+ positional_index = 0
411
+
412
+ for i, value_str in enumerate(arg_values):
413
+ # Check if it's a named argument (name=value)
414
+ if "=" in value_str:
415
+ arg_name, arg_value = value_str.split("=", 1)
416
+ # Find the spec for this argument
417
+ spec = next((s for s in parsed_specs if s[0] == arg_name), None)
418
+ if spec is None:
419
+ console.print(f"[red]Unknown argument: {arg_name}[/red]")
420
+ raise typer.Exit(1)
421
+ name, arg_type, default = spec
422
+ else:
423
+ # Positional argument
424
+ if positional_index >= len(parsed_specs):
425
+ console.print(f"[red]Too many arguments[/red]")
426
+ raise typer.Exit(1)
427
+ name, arg_type, default = parsed_specs[positional_index]
428
+ arg_value = value_str
429
+ positional_index += 1
430
+
431
+ # Convert value to appropriate type
432
+ try:
433
+ click_type = get_click_type(arg_type)
434
+ converted_value = click_type.convert(arg_value, None, None)
435
+ args_dict[name] = converted_value
436
+ except Exception as e:
437
+ console.print(f"[red]Invalid value for {name}: {e}[/red]")
438
+ raise typer.Exit(1)
439
+
440
+ # Fill in defaults for missing arguments
441
+ for name, arg_type, default in parsed_specs:
442
+ if name not in args_dict:
443
+ if default is not None:
444
+ try:
445
+ click_type = get_click_type(arg_type)
446
+ args_dict[name] = click_type.convert(default, None, None)
447
+ except Exception as e:
448
+ console.print(f"[red]Invalid default value for {name}: {e}[/red]")
449
+ raise typer.Exit(1)
450
+ else:
451
+ console.print(f"[red]Missing required argument: {name}[/red]")
452
+ raise typer.Exit(1)
453
+
454
+ return args_dict
455
+
456
+
457
+ def _build_rich_tree(dep_tree: dict, statuses: dict) -> Tree:
458
+ """Build a Rich Tree from dependency tree and statuses.
459
+
460
+ Args:
461
+ dep_tree: Dependency tree structure
462
+ statuses: Task execution statuses
463
+
464
+ Returns:
465
+ Rich Tree for display
466
+ """
467
+ task_name = dep_tree["name"]
468
+ status = statuses.get(task_name)
469
+
470
+ # Determine color based on status
471
+ if status:
472
+ if status.will_run:
473
+ if status.reason == "dependency_triggered":
474
+ color = "yellow"
475
+ label = f"{task_name} (triggered by dependency)"
476
+ else:
477
+ color = "red"
478
+ label = f"{task_name} (stale: {status.reason})"
479
+ else:
480
+ color = "green"
481
+ label = f"{task_name} (fresh)"
482
+ else:
483
+ color = "white"
484
+ label = task_name
485
+
486
+ tree = Tree(f"[{color}]{label}[/{color}]")
487
+
488
+ # Add dependencies
489
+ for dep in dep_tree.get("deps", []):
490
+ dep_tree_obj = _build_rich_tree(dep, statuses)
491
+ tree.add(dep_tree_obj)
492
+
493
+ return tree
494
+
495
+
496
+ def cli():
497
+ """Entry point for the CLI."""
498
+ app()
499
+
500
+
501
+ if __name__ == "__main__":
502
+ app()