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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """
2
2
  Task Tree - A task automation tool with intelligent incremental execution.
3
- @athena: 1b4a97c4bc42
3
+ @athena: 1f9043e194aa
4
4
  """
5
5
 
6
6
  try:
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: 778f231737a1
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
- console.print(
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
- console.print(table)
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: 79ae3e330662
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
- console.print(
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
- console.print(f"[red]Task not found: {task_name}[/red]")
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
- console.print(f"[bold]Task: {task_name}[/bold]")
199
+ logger.info(f"[bold]Task: {task_name}[/bold]")
195
200
  if task.source_file:
196
- console.print(f"Source: {task.source_file}\n")
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
- console.print(syntax)
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: a906cef99324
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
- console.print(
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
- console.print(f"[red]Task not found: {task_name}[/red]")
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
- console.print(f"[red]Error building dependency tree: {e}[/red]")
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
- console.print(tree)
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: 189726c9b6c0
268
+ @athena: f05c0eb014d4
264
269
  """
265
270
  recipe_path = Path("tasktree.yaml")
266
271
  if recipe_path.exists():
267
- console.print("[red]tasktree.yaml already exists[/red]")
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
- console.print(f"[green]Created {recipe_path}[/green]")
301
- console.print("Edit the file to define your tasks")
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: f76c75c12d10
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 or clean_state or reset:
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
- console.print(
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
- console.print("[bold]Available tasks:[/bold]")
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
- console.print(f" - {task_name}")
424
- console.print("\nUse [cyan]tt --list[/cyan] for detailed information")
425
- console.print("Use [cyan]tt <task-name>[/cyan] to run a task")
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: a0ddf4b333d4
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
- console.print(f"[red]Recipe file not found: {tasks_file}[/red]")
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
- console.print("[yellow]No recipe file found[/yellow]")
442
- console.print("State file location depends on recipe file location")
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
- console.print(
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
- console.print("All tasks will run fresh on next execution")
480
+ logger.info("All tasks will run fresh on next execution")
454
481
  else:
455
- console.print(f"[yellow]No state file found at {state_path}[/yellow]")
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: 0ee00c67df25
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
- console.print(f"[red]Recipe file not found: {recipe_file}[/red]")
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
- console.print(f"[red]{e}[/red]")
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
- console.print(f"[red]Error parsing recipe: {e}[/red]")
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
- 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)
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: 207f7635a60d
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
- console.print(
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
- console.print(f"[red]Environment not found: {env}[/red]")
534
- console.print("\nAvailable environments:")
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
- console.print(f" - {env_name}")
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
- console.print(f"[red]Task not found: {task_name}[/red]")
543
- console.print("\nAvailable tasks:")
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
- console.print(f" - {name}")
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
- console.print(f"[red]Error in task template: {e}[/red]")
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 exec_task_name, exec_task_args in execution_order:
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
- # If task has arguments, append args hash to create unique cache key
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(task_name, args_dict, force=force, only=only)
602
- console.print(
603
- f"[green]{get_action_success_string()} Task '{task_name}' completed successfully[/green]"
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
- console.print(
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(arg_specs: list[str], arg_values: list[str]) -> dict[str, Any]:
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
- arg_specs: Task argument specifications with types and defaults
618
- arg_values: Raw argument values from command line (positional or named)
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
- Dictionary mapping argument names to typed, validated values
654
+ Dictionary mapping argument names to typed, validated values
622
655
 
623
656
  Raises:
624
- typer.Exit: If arguments are invalid, missing, or unknown
657
+ typer.Exit: If arguments are invalid, missing, or unknown
625
658
 
626
- @athena: 2072a35f9d11
659
+ @athena: d9a7ea55c3d6
627
660
  """
628
661
  if not arg_specs:
629
662
  if arg_values:
630
- console.print("[red]Task does not accept arguments[/red]")
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
- console.print(f"[red]Unknown argument: {arg_name}[/red]")
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
- console.print("[red]Too many arguments[/red]")
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
- console.print(
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
- console.print(
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
- console.print(f"[red]Invalid value for {spec.name}: {e}[/red]")
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
- console.print(
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
- console.print(f"[red]Missing required argument: {spec.name}[/red]")
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()