tasktree 0.0.22__py3-none-any.whl → 0.0.23__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 +1 -1
- tasktree/cli.py +145 -112
- tasktree/console_logger.py +66 -0
- tasktree/docker.py +14 -8
- tasktree/executor.py +161 -41
- tasktree/graph.py +3 -3
- tasktree/hasher.py +5 -5
- tasktree/logging.py +112 -0
- tasktree/parser.py +20 -17
- tasktree/process_runner.py +411 -0
- tasktree/substitution.py +2 -2
- tasktree/types.py +3 -3
- {tasktree-0.0.22.dist-info → tasktree-0.0.23.dist-info}/METADATA +201 -4
- tasktree-0.0.23.dist-info/RECORD +17 -0
- tasktree-0.0.22.dist-info/RECORD +0 -14
- {tasktree-0.0.22.dist-info → tasktree-0.0.23.dist-info}/WHEEL +0 -0
- {tasktree-0.0.22.dist-info → tasktree-0.0.23.dist-info}/entry_points.txt +0 -0
tasktree/__init__.py
CHANGED
tasktree/cli.py
CHANGED
|
@@ -12,6 +12,7 @@ import sys
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any, List, Optional
|
|
14
14
|
|
|
15
|
+
import click
|
|
15
16
|
import typer
|
|
16
17
|
import yaml
|
|
17
18
|
from rich.console import Console
|
|
@@ -28,10 +29,14 @@ from tasktree.graph import (
|
|
|
28
29
|
resolve_self_references,
|
|
29
30
|
)
|
|
30
31
|
from tasktree.hasher import hash_task, hash_args
|
|
32
|
+
from tasktree.console_logger import ConsoleLogger, Logger
|
|
33
|
+
from tasktree.logging import LogLevel
|
|
31
34
|
from tasktree.parser import Recipe, find_recipe_file, parse_arg_spec, parse_recipe
|
|
35
|
+
from tasktree.process_runner import TaskOutputTypes, make_process_runner
|
|
32
36
|
from tasktree.state import StateManager
|
|
33
37
|
from tasktree.types import get_click_type
|
|
34
38
|
|
|
39
|
+
|
|
35
40
|
app = typer.Typer(
|
|
36
41
|
help="Task Tree - A task automation tool with intelligent incremental execution",
|
|
37
42
|
add_completion=False,
|
|
@@ -123,15 +128,15 @@ def _format_task_arguments(arg_specs: list[str | dict]) -> str:
|
|
|
123
128
|
return " ".join(formatted_parts)
|
|
124
129
|
|
|
125
130
|
|
|
126
|
-
def _list_tasks(tasks_file: Optional[str] = None):
|
|
131
|
+
def _list_tasks(logger: Logger, tasks_file: Optional[str] = None):
|
|
127
132
|
"""
|
|
128
133
|
List all available tasks with descriptions.
|
|
129
|
-
@athena:
|
|
134
|
+
@athena: 907819fc0cc7
|
|
130
135
|
"""
|
|
131
|
-
recipe = _get_recipe(tasks_file)
|
|
136
|
+
recipe = _get_recipe(logger, tasks_file)
|
|
132
137
|
if recipe is None:
|
|
133
|
-
|
|
134
|
-
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
|
|
138
|
+
logger.error(
|
|
139
|
+
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]",
|
|
135
140
|
)
|
|
136
141
|
raise typer.Exit(1)
|
|
137
142
|
|
|
@@ -169,31 +174,31 @@ def _list_tasks(tasks_file: Optional[str] = None):
|
|
|
169
174
|
|
|
170
175
|
table.add_row(task_name, args_formatted, desc)
|
|
171
176
|
|
|
172
|
-
|
|
177
|
+
logger.info(table)
|
|
173
178
|
|
|
174
179
|
|
|
175
|
-
def _show_task(task_name: str, tasks_file: Optional[str] = None):
|
|
180
|
+
def _show_task(logger: Logger, task_name: str, tasks_file: Optional[str] = None):
|
|
176
181
|
"""
|
|
177
182
|
Show task definition with syntax highlighting.
|
|
178
|
-
@athena:
|
|
183
|
+
@athena: a6b71673d4b7
|
|
179
184
|
"""
|
|
180
185
|
# Pass task_name as root_task for lazy variable evaluation
|
|
181
|
-
recipe = _get_recipe(tasks_file, root_task=task_name)
|
|
186
|
+
recipe = _get_recipe(logger, tasks_file, root_task=task_name)
|
|
182
187
|
if recipe is None:
|
|
183
|
-
|
|
184
|
-
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
|
|
188
|
+
logger.error(
|
|
189
|
+
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]",
|
|
185
190
|
)
|
|
186
191
|
raise typer.Exit(1)
|
|
187
192
|
|
|
188
193
|
task = recipe.get_task(task_name)
|
|
189
194
|
if task is None:
|
|
190
|
-
|
|
195
|
+
logger.error(f"[red]Task not found: {task_name}[/red]")
|
|
191
196
|
raise typer.Exit(1)
|
|
192
197
|
|
|
193
198
|
# Show source file info
|
|
194
|
-
|
|
199
|
+
logger.info(f"[bold]Task: {task_name}[/bold]")
|
|
195
200
|
if task.source_file:
|
|
196
|
-
|
|
201
|
+
logger.info(f"Source: {task.source_file}\n")
|
|
197
202
|
|
|
198
203
|
# Create YAML representation
|
|
199
204
|
task_yaml = {
|
|
@@ -224,47 +229,47 @@ def _show_task(task_name: str, tasks_file: Optional[str] = None):
|
|
|
224
229
|
# Format and highlight using Rich
|
|
225
230
|
yaml_str = yaml.dump(task_yaml, default_flow_style=False, sort_keys=False)
|
|
226
231
|
syntax = Syntax(yaml_str, "yaml", theme="ansi_light", line_numbers=False)
|
|
227
|
-
|
|
232
|
+
logger.info(syntax)
|
|
228
233
|
|
|
229
234
|
|
|
230
|
-
def _show_tree(task_name: str, tasks_file: Optional[str] = None):
|
|
235
|
+
def _show_tree(logger: Logger, task_name: str, tasks_file: Optional[str] = None):
|
|
231
236
|
"""
|
|
232
237
|
Show dependency tree structure.
|
|
233
|
-
@athena:
|
|
238
|
+
@athena: 88fbc03f4915
|
|
234
239
|
"""
|
|
235
240
|
# Pass task_name as root_task for lazy variable evaluation
|
|
236
|
-
recipe = _get_recipe(tasks_file, root_task=task_name)
|
|
241
|
+
recipe = _get_recipe(logger, tasks_file, root_task=task_name)
|
|
237
242
|
if recipe is None:
|
|
238
|
-
|
|
239
|
-
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
|
|
243
|
+
logger.error(
|
|
244
|
+
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]",
|
|
240
245
|
)
|
|
241
246
|
raise typer.Exit(1)
|
|
242
247
|
|
|
243
248
|
task = recipe.get_task(task_name)
|
|
244
249
|
if task is None:
|
|
245
|
-
|
|
250
|
+
logger.error(f"[red]Task not found: {task_name}[/red]")
|
|
246
251
|
raise typer.Exit(1)
|
|
247
252
|
|
|
248
253
|
# Build dependency tree
|
|
249
254
|
try:
|
|
250
255
|
dep_tree = build_dependency_tree(recipe, task_name)
|
|
251
256
|
except Exception as e:
|
|
252
|
-
|
|
257
|
+
logger.error(f"[red]Error building dependency tree: {e}[/red]")
|
|
253
258
|
raise typer.Exit(1)
|
|
254
259
|
|
|
255
260
|
# Build Rich tree
|
|
256
261
|
tree = _build_rich_tree(dep_tree)
|
|
257
|
-
|
|
262
|
+
logger.info(tree)
|
|
258
263
|
|
|
259
264
|
|
|
260
|
-
def _init_recipe():
|
|
265
|
+
def _init_recipe(logger: Logger):
|
|
261
266
|
"""
|
|
262
267
|
Create a blank recipe file with commented examples.
|
|
263
|
-
@athena:
|
|
268
|
+
@athena: f05c0eb014d4
|
|
264
269
|
"""
|
|
265
270
|
recipe_path = Path("tasktree.yaml")
|
|
266
271
|
if recipe_path.exists():
|
|
267
|
-
|
|
272
|
+
logger.error("[red]tasktree.yaml already exists[/red]")
|
|
268
273
|
raise typer.Exit(1)
|
|
269
274
|
|
|
270
275
|
template = """# Task Tree Recipe
|
|
@@ -297,8 +302,8 @@ tasks:
|
|
|
297
302
|
"""
|
|
298
303
|
|
|
299
304
|
recipe_path.write_text(template)
|
|
300
|
-
|
|
301
|
-
|
|
305
|
+
logger.info(f"[green]Created {recipe_path}[/green]")
|
|
306
|
+
logger.info("Edit the file to define your tasks")
|
|
302
307
|
|
|
303
308
|
|
|
304
309
|
def _version_callback(value: bool):
|
|
@@ -340,12 +345,6 @@ def main(
|
|
|
340
345
|
clean: Optional[bool] = typer.Option(
|
|
341
346
|
None, "--clean", "-c", help="Remove state file (reset task cache)"
|
|
342
347
|
),
|
|
343
|
-
clean_state: Optional[bool] = typer.Option(
|
|
344
|
-
None, "--clean-state", "-C", help="Remove state file (reset task cache)"
|
|
345
|
-
),
|
|
346
|
-
reset: Optional[bool] = typer.Option(
|
|
347
|
-
None, "--reset", "-r", help="Remove state file (reset task cache)"
|
|
348
|
-
),
|
|
349
348
|
force: Optional[bool] = typer.Option(
|
|
350
349
|
None, "--force", "-f", help="Force re-run all tasks (ignore freshness)"
|
|
351
350
|
),
|
|
@@ -358,6 +357,28 @@ def main(
|
|
|
358
357
|
env: Optional[str] = typer.Option(
|
|
359
358
|
None, "--env", "-e", help="Override environment for all tasks"
|
|
360
359
|
),
|
|
360
|
+
log_level: str = typer.Option(
|
|
361
|
+
"info",
|
|
362
|
+
"--log-level",
|
|
363
|
+
"-L",
|
|
364
|
+
click_type=click.Choice(
|
|
365
|
+
[l.name.lower() for l in LogLevel], case_sensitive=False
|
|
366
|
+
),
|
|
367
|
+
help="""Control verbosity of tasktree's diagnostic messages""",
|
|
368
|
+
),
|
|
369
|
+
task_output: Optional[str] = typer.Option(
|
|
370
|
+
None,
|
|
371
|
+
"--task-output",
|
|
372
|
+
"-O",
|
|
373
|
+
click_type=click.Choice([t.value for t in TaskOutputTypes], case_sensitive=False),
|
|
374
|
+
help="""Control task subprocess output display:
|
|
375
|
+
|
|
376
|
+
- all: show both stdout and stderr output from tasks\n
|
|
377
|
+
- out: show only stdout from tasks\n
|
|
378
|
+
- err: show only stderr from tasks\n
|
|
379
|
+
- on-err: show stderr from tasks, but only if the task fails. (all stdout is suppressed)
|
|
380
|
+
- none: suppress all output)""",
|
|
381
|
+
),
|
|
361
382
|
task_args: Optional[List[str]] = typer.Argument(
|
|
362
383
|
None, help="Task name and arguments"
|
|
363
384
|
),
|
|
@@ -374,72 +395,78 @@ def main(
|
|
|
374
395
|
tt deploy prod region=us-1 # Run 'deploy' with arguments
|
|
375
396
|
tt --list # List all tasks
|
|
376
397
|
tt --tree test # Show dependency tree for 'test'
|
|
377
|
-
@athena:
|
|
398
|
+
@athena: 40e6fdbe6100
|
|
378
399
|
"""
|
|
379
400
|
|
|
401
|
+
logger = ConsoleLogger(console, LogLevel(LogLevel[log_level.upper()]))
|
|
402
|
+
|
|
380
403
|
if list_opt:
|
|
381
|
-
_list_tasks(tasks_file)
|
|
404
|
+
_list_tasks(logger, tasks_file)
|
|
382
405
|
raise typer.Exit()
|
|
383
406
|
|
|
384
407
|
if show:
|
|
385
|
-
_show_task(show, tasks_file)
|
|
408
|
+
_show_task(logger, show, tasks_file)
|
|
386
409
|
raise typer.Exit()
|
|
387
410
|
|
|
388
411
|
if tree:
|
|
389
|
-
_show_tree(tree, tasks_file)
|
|
412
|
+
_show_tree(logger, tree, tasks_file)
|
|
390
413
|
raise typer.Exit()
|
|
391
414
|
|
|
392
415
|
if init:
|
|
393
|
-
_init_recipe()
|
|
416
|
+
_init_recipe(logger)
|
|
394
417
|
raise typer.Exit()
|
|
395
418
|
|
|
396
|
-
if clean
|
|
397
|
-
_clean_state(tasks_file)
|
|
419
|
+
if clean:
|
|
420
|
+
_clean_state(logger, tasks_file)
|
|
398
421
|
raise typer.Exit()
|
|
399
422
|
|
|
400
423
|
if task_args:
|
|
401
424
|
# --only implies --force
|
|
402
425
|
force_execution = force or only or False
|
|
403
426
|
_execute_dynamic_task(
|
|
427
|
+
logger,
|
|
404
428
|
task_args,
|
|
405
429
|
force=force_execution,
|
|
406
430
|
only=only or False,
|
|
407
431
|
env=env,
|
|
408
432
|
tasks_file=tasks_file,
|
|
433
|
+
task_output=task_output,
|
|
409
434
|
)
|
|
410
435
|
else:
|
|
411
|
-
recipe = _get_recipe(tasks_file)
|
|
436
|
+
recipe = _get_recipe(logger, tasks_file)
|
|
412
437
|
if recipe is None:
|
|
413
|
-
|
|
414
|
-
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
|
|
438
|
+
logger.error(
|
|
439
|
+
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]",
|
|
440
|
+
)
|
|
441
|
+
logger.info(
|
|
442
|
+
"Run [cyan]tt --init[/cyan] to create a blank recipe file",
|
|
415
443
|
)
|
|
416
|
-
console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
|
|
417
444
|
raise typer.Exit(1)
|
|
418
445
|
|
|
419
|
-
|
|
446
|
+
logger.info("[bold]Available tasks:[/bold]")
|
|
420
447
|
for task_name in sorted(recipe.task_names()):
|
|
421
448
|
task = recipe.get_task(task_name)
|
|
422
449
|
if task and not task.private:
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
450
|
+
logger.info(f" - {task_name}")
|
|
451
|
+
logger.info("\nUse [cyan]tt --list[/cyan] for detailed information")
|
|
452
|
+
logger.info("Use [cyan]tt <task-name>[/cyan] to run a task")
|
|
426
453
|
|
|
427
454
|
|
|
428
|
-
def _clean_state(tasks_file: Optional[str] = None) -> None:
|
|
455
|
+
def _clean_state(logger: Logger, tasks_file: Optional[str] = None) -> None:
|
|
429
456
|
"""
|
|
430
457
|
Remove the .tasktree-state file to reset task execution state.
|
|
431
|
-
@athena:
|
|
458
|
+
@athena: 2f270f8a2d70
|
|
432
459
|
"""
|
|
433
460
|
if tasks_file:
|
|
434
461
|
recipe_path = Path(tasks_file)
|
|
435
462
|
if not recipe_path.exists():
|
|
436
|
-
|
|
463
|
+
logger.error(f"[red]Recipe file not found: {tasks_file}[/red]")
|
|
437
464
|
raise typer.Exit(1)
|
|
438
465
|
else:
|
|
439
466
|
recipe_path = find_recipe_file()
|
|
440
467
|
if recipe_path is None:
|
|
441
|
-
|
|
442
|
-
|
|
468
|
+
logger.warn("[yellow]No recipe file found[/yellow]")
|
|
469
|
+
logger.info("State file location depends on recipe file location")
|
|
443
470
|
raise typer.Exit(1)
|
|
444
471
|
|
|
445
472
|
project_root = recipe_path.parent
|
|
@@ -447,30 +474,31 @@ def _clean_state(tasks_file: Optional[str] = None) -> None:
|
|
|
447
474
|
|
|
448
475
|
if state_path.exists():
|
|
449
476
|
state_path.unlink()
|
|
450
|
-
|
|
451
|
-
f"[green]{get_action_success_string()} Removed {state_path}[/green]"
|
|
477
|
+
logger.info(
|
|
478
|
+
f"[green]{get_action_success_string()} Removed {state_path}[/green]",
|
|
452
479
|
)
|
|
453
|
-
|
|
480
|
+
logger.info("All tasks will run fresh on next execution")
|
|
454
481
|
else:
|
|
455
|
-
|
|
482
|
+
logger.info(f"[yellow]No state file found at {state_path}[/yellow]")
|
|
456
483
|
|
|
457
484
|
|
|
458
485
|
def _get_recipe(
|
|
459
|
-
recipe_file: Optional[str] = None, root_task: Optional[str] = None
|
|
486
|
+
logger: Logger, recipe_file: Optional[str] = None, root_task: Optional[str] = None
|
|
460
487
|
) -> Optional[Recipe]:
|
|
461
488
|
"""
|
|
462
489
|
Get parsed recipe or None if not found.
|
|
463
490
|
|
|
464
491
|
Args:
|
|
492
|
+
logger_fn: Logger function for output
|
|
465
493
|
recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
|
|
466
494
|
root_task: Optional root task for lazy variable evaluation. If provided, only variables
|
|
467
495
|
reachable from this task will be evaluated (performance optimization).
|
|
468
|
-
@athena:
|
|
496
|
+
@athena: ded906495d18
|
|
469
497
|
"""
|
|
470
498
|
if recipe_file:
|
|
471
499
|
recipe_path = Path(recipe_file)
|
|
472
500
|
if not recipe_path.exists():
|
|
473
|
-
|
|
501
|
+
logger.error(f"[red]Recipe file not found: {recipe_file}[/red]")
|
|
474
502
|
raise typer.Exit(1)
|
|
475
503
|
# When explicitly specified, project root is current working directory
|
|
476
504
|
project_root = Path.cwd()
|
|
@@ -481,7 +509,7 @@ def _get_recipe(
|
|
|
481
509
|
return None
|
|
482
510
|
except ValueError as e:
|
|
483
511
|
# Multiple recipe files found
|
|
484
|
-
|
|
512
|
+
logger.error(f"[red]{e}[/red]")
|
|
485
513
|
raise typer.Exit(1)
|
|
486
514
|
# When auto-discovered, project root is recipe file's parent
|
|
487
515
|
project_root = None
|
|
@@ -489,28 +517,32 @@ def _get_recipe(
|
|
|
489
517
|
try:
|
|
490
518
|
return parse_recipe(recipe_path, project_root, root_task)
|
|
491
519
|
except Exception as e:
|
|
492
|
-
|
|
520
|
+
logger.error(f"[red]Error parsing recipe: {e}[/red]")
|
|
493
521
|
raise typer.Exit(1)
|
|
494
522
|
|
|
495
523
|
|
|
496
524
|
def _execute_dynamic_task(
|
|
525
|
+
logger: Logger,
|
|
497
526
|
args: list[str],
|
|
498
527
|
force: bool = False,
|
|
499
528
|
only: bool = False,
|
|
500
529
|
env: Optional[str] = None,
|
|
501
530
|
tasks_file: Optional[str] = None,
|
|
531
|
+
task_output: str | None = None,
|
|
502
532
|
) -> None:
|
|
503
533
|
"""
|
|
504
534
|
Execute a task with its dependencies and handle argument parsing.
|
|
505
535
|
|
|
506
536
|
Args:
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
537
|
+
logger: Logger interface for output
|
|
538
|
+
args: Task name followed by optional task arguments
|
|
539
|
+
force: Force re-execution even if task is up-to-date
|
|
540
|
+
only: Execute only the specified task, skip dependencies
|
|
541
|
+
env: Override environment for task execution
|
|
542
|
+
tasks_file: Path to recipe file (optional)
|
|
543
|
+
task_output: Control task subprocess output (all, out, err, on-err, none)
|
|
512
544
|
|
|
513
|
-
@athena:
|
|
545
|
+
@athena: 36ae914a5bc7
|
|
514
546
|
"""
|
|
515
547
|
if not args:
|
|
516
548
|
return
|
|
@@ -519,10 +551,10 @@ def _execute_dynamic_task(
|
|
|
519
551
|
task_args = args[1:]
|
|
520
552
|
|
|
521
553
|
# Pass task_name as root_task for lazy variable evaluation
|
|
522
|
-
recipe = _get_recipe(tasks_file, root_task=task_name)
|
|
554
|
+
recipe = _get_recipe(logger, tasks_file, root_task=task_name)
|
|
523
555
|
if recipe is None:
|
|
524
|
-
|
|
525
|
-
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
|
|
556
|
+
logger.error(
|
|
557
|
+
"[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]",
|
|
526
558
|
)
|
|
527
559
|
raise typer.Exit(1)
|
|
528
560
|
|
|
@@ -530,30 +562,30 @@ def _execute_dynamic_task(
|
|
|
530
562
|
if env:
|
|
531
563
|
# Validate that the environment exists
|
|
532
564
|
if not recipe.get_environment(env):
|
|
533
|
-
|
|
534
|
-
|
|
565
|
+
logger.error(f"[red]Environment not found: {env}[/red]")
|
|
566
|
+
logger.info("\nAvailable environments:")
|
|
535
567
|
for env_name in sorted(recipe.environments.keys()):
|
|
536
|
-
|
|
568
|
+
logger.info(f" - {env_name}")
|
|
537
569
|
raise typer.Exit(1)
|
|
538
570
|
recipe.global_env_override = env
|
|
539
571
|
|
|
540
572
|
task = recipe.get_task(task_name)
|
|
541
573
|
if task is None:
|
|
542
|
-
|
|
543
|
-
|
|
574
|
+
logger.error(f"[red]Task not found: {task_name}[/red]")
|
|
575
|
+
logger.info("\nAvailable tasks:")
|
|
544
576
|
for name in sorted(recipe.task_names()):
|
|
545
577
|
task = recipe.get_task(name)
|
|
546
578
|
if task and not task.private:
|
|
547
|
-
|
|
579
|
+
logger.info(f" - {name}")
|
|
548
580
|
raise typer.Exit(1)
|
|
549
581
|
|
|
550
582
|
# Parse task arguments
|
|
551
|
-
args_dict = _parse_task_args(task.args, task_args)
|
|
583
|
+
args_dict = _parse_task_args(logger, task.args, task_args)
|
|
552
584
|
|
|
553
585
|
# Create executor and state manager
|
|
554
586
|
state = StateManager(recipe.project_root)
|
|
555
587
|
state.load()
|
|
556
|
-
executor = Executor(recipe, state)
|
|
588
|
+
executor = Executor(recipe, state, logger, make_process_runner)
|
|
557
589
|
|
|
558
590
|
# Resolve execution order to determine which tasks will actually run
|
|
559
591
|
# This is important for correct state pruning after template substitution
|
|
@@ -568,14 +600,13 @@ def _execute_dynamic_task(
|
|
|
568
600
|
# This substitutes {{ self.inputs.* }} and {{ self.outputs.* }} templates
|
|
569
601
|
resolve_self_references(recipe, execution_order)
|
|
570
602
|
except ValueError as e:
|
|
571
|
-
|
|
603
|
+
logger.error(f"[red]Error in task template: {e}[/red]")
|
|
572
604
|
raise typer.Exit(1)
|
|
573
605
|
|
|
574
606
|
# Prune state based on tasks that will actually execute (with their specific arguments)
|
|
575
607
|
# This ensures template-substituted dependencies are handled correctly
|
|
576
608
|
valid_hashes = set()
|
|
577
|
-
for
|
|
578
|
-
task = recipe.tasks[exec_task_name]
|
|
609
|
+
for _, task in recipe.tasks.items():
|
|
579
610
|
# Compute base task hash
|
|
580
611
|
task_hash = hash_task(
|
|
581
612
|
task.cmd,
|
|
@@ -586,48 +617,50 @@ def _execute_dynamic_task(
|
|
|
586
617
|
task.deps,
|
|
587
618
|
)
|
|
588
619
|
|
|
589
|
-
|
|
590
|
-
if exec_task_args:
|
|
591
|
-
args_hash = hash_args(exec_task_args)
|
|
592
|
-
cache_key = f"{task_hash}__{args_hash}"
|
|
593
|
-
else:
|
|
594
|
-
cache_key = task_hash
|
|
595
|
-
|
|
596
|
-
valid_hashes.add(cache_key)
|
|
620
|
+
valid_hashes.add(task_hash)
|
|
597
621
|
|
|
598
622
|
state.prune(valid_hashes)
|
|
599
623
|
state.save()
|
|
600
624
|
try:
|
|
601
|
-
executor.execute_task(
|
|
602
|
-
|
|
603
|
-
|
|
625
|
+
executor.execute_task(
|
|
626
|
+
task_name,
|
|
627
|
+
TaskOutputTypes(task_output.lower()) if task_output is not None else None,
|
|
628
|
+
args_dict,
|
|
629
|
+
force=force,
|
|
630
|
+
only=only,
|
|
631
|
+
)
|
|
632
|
+
logger.info(
|
|
633
|
+
f"[green]{get_action_success_string()} Task '{task_name}' completed successfully[/green]",
|
|
604
634
|
)
|
|
605
635
|
except Exception as e:
|
|
606
|
-
|
|
636
|
+
logger.error(
|
|
607
637
|
f"[red]{get_action_failure_string()} Task '{task_name}' failed: {e}[/red]"
|
|
608
638
|
)
|
|
609
639
|
raise typer.Exit(1)
|
|
610
640
|
|
|
611
641
|
|
|
612
|
-
def _parse_task_args(
|
|
642
|
+
def _parse_task_args(
|
|
643
|
+
logger: Logger, arg_specs: list[str], arg_values: list[str]
|
|
644
|
+
) -> dict[str, Any]:
|
|
613
645
|
"""
|
|
614
646
|
Parse and validate task arguments from command line values.
|
|
615
647
|
|
|
616
648
|
Args:
|
|
617
|
-
|
|
618
|
-
|
|
649
|
+
logger: Logger interface for output
|
|
650
|
+
arg_specs: Task argument specifications with types and defaults
|
|
651
|
+
arg_values: Raw argument values from command line (positional or named)
|
|
619
652
|
|
|
620
653
|
Returns:
|
|
621
|
-
|
|
654
|
+
Dictionary mapping argument names to typed, validated values
|
|
622
655
|
|
|
623
656
|
Raises:
|
|
624
|
-
|
|
657
|
+
typer.Exit: If arguments are invalid, missing, or unknown
|
|
625
658
|
|
|
626
|
-
@athena:
|
|
659
|
+
@athena: d9a7ea55c3d6
|
|
627
660
|
"""
|
|
628
661
|
if not arg_specs:
|
|
629
662
|
if arg_values:
|
|
630
|
-
|
|
663
|
+
logger.error("[red]Task does not accept arguments[/red]")
|
|
631
664
|
raise typer.Exit(1)
|
|
632
665
|
return {}
|
|
633
666
|
|
|
@@ -646,12 +679,12 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
646
679
|
# Find the spec for this argument
|
|
647
680
|
spec = next((s for s in parsed_specs if s.name == arg_name), None)
|
|
648
681
|
if spec is None:
|
|
649
|
-
|
|
682
|
+
logger.error(f"[red]Unknown argument: {arg_name}[/red]")
|
|
650
683
|
raise typer.Exit(1)
|
|
651
684
|
else:
|
|
652
685
|
# Positional argument
|
|
653
686
|
if positional_index >= len(parsed_specs):
|
|
654
|
-
|
|
687
|
+
logger.error("[red]Too many arguments[/red]")
|
|
655
688
|
raise typer.Exit(1)
|
|
656
689
|
spec = parsed_specs[positional_index]
|
|
657
690
|
arg_value = value_str
|
|
@@ -666,11 +699,11 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
666
699
|
|
|
667
700
|
# Validate choices after type conversion
|
|
668
701
|
if spec.choices is not None and converted_value not in spec.choices:
|
|
669
|
-
|
|
670
|
-
f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]"
|
|
702
|
+
logger.error(
|
|
703
|
+
f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]",
|
|
671
704
|
)
|
|
672
|
-
|
|
673
|
-
f"Valid choices: {', '.join(repr(c) for c in spec.choices)}"
|
|
705
|
+
logger.info(
|
|
706
|
+
f"Valid choices: {', '.join(repr(c) for c in spec.choices)}",
|
|
674
707
|
)
|
|
675
708
|
raise typer.Exit(1)
|
|
676
709
|
|
|
@@ -678,7 +711,7 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
678
711
|
except typer.Exit:
|
|
679
712
|
raise # Re-raise typer.Exit without wrapping
|
|
680
713
|
except Exception as e:
|
|
681
|
-
|
|
714
|
+
logger.error(f"[red]Invalid value for {spec.name}: {e}[/red]")
|
|
682
715
|
raise typer.Exit(1)
|
|
683
716
|
|
|
684
717
|
# Fill in defaults for missing arguments
|
|
@@ -691,12 +724,12 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
691
724
|
)
|
|
692
725
|
args_dict[spec.name] = click_type.convert(spec.default, None, None)
|
|
693
726
|
except Exception as e:
|
|
694
|
-
|
|
695
|
-
f"[red]Invalid default value for {spec.name}: {e}[/red]"
|
|
727
|
+
logger.error(
|
|
728
|
+
f"[red]Invalid default value for {spec.name}: {e}[/red]",
|
|
696
729
|
)
|
|
697
730
|
raise typer.Exit(1)
|
|
698
731
|
else:
|
|
699
|
-
|
|
732
|
+
logger.error(f"[red]Missing required argument: {spec.name}[/red]")
|
|
700
733
|
raise typer.Exit(1)
|
|
701
734
|
|
|
702
735
|
return args_dict
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from tasktree.logging import Logger, LogLevel
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConsoleLogger(Logger):
|
|
6
|
+
"""
|
|
7
|
+
Console-based logger implementation using Rich for formatting.
|
|
8
|
+
|
|
9
|
+
Filters log messages based on the current log level. Messages with severity
|
|
10
|
+
lower than the current level are suppressed. Supports a stack-based level
|
|
11
|
+
management system for temporary verbosity changes.
|
|
12
|
+
@athena: a892913fc11c
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, console: Console, level: LogLevel = LogLevel.INFO) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Initialize the console logger.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
console: Rich Console instance to use for output
|
|
21
|
+
level: Initial log level (default: INFO)
|
|
22
|
+
@athena: 388d9c273a6a
|
|
23
|
+
"""
|
|
24
|
+
self._console = console
|
|
25
|
+
self._levels = [level]
|
|
26
|
+
|
|
27
|
+
def log(self, level: LogLevel = LogLevel.INFO, *args, **kwargs) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Log a message to the console if it meets the current level threshold.
|
|
30
|
+
|
|
31
|
+
Messages are only printed if their level is at or above the current level
|
|
32
|
+
(i.e., level.value <= current_level.value, since lower values = higher severity).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
level: The severity level of this message (default: INFO)
|
|
36
|
+
*args: Positional arguments passed to Rich Console.print()
|
|
37
|
+
**kwargs: Keyword arguments passed to Rich Console.print()
|
|
38
|
+
@athena: efae38733da1
|
|
39
|
+
"""
|
|
40
|
+
if self._levels[-1].value >= level.value:
|
|
41
|
+
self._console.print(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
def push_level(self, level: LogLevel) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Push a new log level onto the stack.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
level: The new log level to activate
|
|
49
|
+
@athena: 07c7493570b1
|
|
50
|
+
"""
|
|
51
|
+
self._levels.append(level)
|
|
52
|
+
|
|
53
|
+
def pop_level(self) -> LogLevel:
|
|
54
|
+
"""
|
|
55
|
+
Pop the current log level and return to the previous level.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The log level that was popped
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RuntimeError: If attempting to pop the base (initial) log level
|
|
62
|
+
@athena: 65ac19e00168
|
|
63
|
+
"""
|
|
64
|
+
if len(self._levels) <= 1:
|
|
65
|
+
raise RuntimeError("Cannot pop the base log level")
|
|
66
|
+
return self._levels.pop()
|