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 +42 -0
- tasktree/cli.py +502 -0
- tasktree/executor.py +365 -0
- tasktree/graph.py +139 -0
- tasktree/hasher.py +74 -0
- tasktree/parser.py +300 -0
- tasktree/state.py +119 -0
- tasktree/tasks.py +8 -0
- tasktree/types.py +130 -0
- tasktree-0.0.1.dist-info/METADATA +387 -0
- tasktree-0.0.1.dist-info/RECORD +13 -0
- tasktree-0.0.1.dist-info/WHEEL +4 -0
- tasktree-0.0.1.dist-info/entry_points.txt +2 -0
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()
|