tasktree 0.0.21__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
@@ -21,12 +22,21 @@ from rich.tree import Tree
21
22
 
22
23
  from tasktree import __version__
23
24
  from tasktree.executor import Executor
24
- from tasktree.graph import build_dependency_tree, resolve_execution_order, resolve_dependency_output_references, resolve_self_references
25
+ from tasktree.graph import (
26
+ build_dependency_tree,
27
+ resolve_execution_order,
28
+ resolve_dependency_output_references,
29
+ resolve_self_references,
30
+ )
25
31
  from tasktree.hasher import hash_task, hash_args
32
+ from tasktree.console_logger import ConsoleLogger, Logger
33
+ from tasktree.logging import LogLevel
26
34
  from tasktree.parser import Recipe, find_recipe_file, parse_arg_spec, parse_recipe
35
+ from tasktree.process_runner import TaskOutputTypes, make_process_runner
27
36
  from tasktree.state import StateManager
28
37
  from tasktree.types import get_click_type
29
38
 
39
+
30
40
  app = typer.Typer(
31
41
  help="Task Tree - A task automation tool with intelligent incremental execution",
32
42
  add_completion=False,
@@ -118,14 +128,16 @@ def _format_task_arguments(arg_specs: list[str | dict]) -> str:
118
128
  return " ".join(formatted_parts)
119
129
 
120
130
 
121
- def _list_tasks(tasks_file: Optional[str] = None):
131
+ def _list_tasks(logger: Logger, tasks_file: Optional[str] = None):
122
132
  """
123
133
  List all available tasks with descriptions.
124
- @athena: 778f231737a1
134
+ @athena: 907819fc0cc7
125
135
  """
126
- recipe = _get_recipe(tasks_file)
136
+ recipe = _get_recipe(logger, tasks_file)
127
137
  if recipe is None:
128
- console.print("[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]",
140
+ )
129
141
  raise typer.Exit(1)
130
142
 
131
143
  # Calculate maximum task name length for fixed-width column (only visible tasks)
@@ -134,13 +146,17 @@ def _list_tasks(tasks_file: Optional[str] = None):
134
146
  task = recipe.get_task(name)
135
147
  if task and not task.private:
136
148
  visible_task_names.append(name)
137
- max_task_name_len = max(len(name) for name in visible_task_names) if visible_task_names else 0
149
+ max_task_name_len = (
150
+ max(len(name) for name in visible_task_names) if visible_task_names else 0
151
+ )
138
152
 
139
153
  # Create borderless table with three columns
140
154
  table = Table(show_edge=False, show_header=False, box=None, padding=(0, 2))
141
155
 
142
- # Command column: fixed width to accommodate longest task name
143
- table.add_column("Command", style="bold cyan", no_wrap=True, width=max_task_name_len)
156
+ # Command column: fixed width to accommodate the longest task name
157
+ table.add_column(
158
+ "Command", style="bold cyan", no_wrap=True, width=max_task_name_len
159
+ )
144
160
 
145
161
  # Arguments column: allow wrapping with sensible max width
146
162
  table.add_column("Arguments", style="white", max_width=60)
@@ -158,29 +174,31 @@ def _list_tasks(tasks_file: Optional[str] = None):
158
174
 
159
175
  table.add_row(task_name, args_formatted, desc)
160
176
 
161
- console.print(table)
177
+ logger.info(table)
162
178
 
163
179
 
164
- 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):
165
181
  """
166
182
  Show task definition with syntax highlighting.
167
- @athena: 79ae3e330662
183
+ @athena: a6b71673d4b7
168
184
  """
169
185
  # Pass task_name as root_task for lazy variable evaluation
170
- recipe = _get_recipe(tasks_file, root_task=task_name)
186
+ recipe = _get_recipe(logger, tasks_file, root_task=task_name)
171
187
  if recipe is None:
172
- console.print("[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]",
190
+ )
173
191
  raise typer.Exit(1)
174
192
 
175
193
  task = recipe.get_task(task_name)
176
194
  if task is None:
177
- console.print(f"[red]Task not found: {task_name}[/red]")
195
+ logger.error(f"[red]Task not found: {task_name}[/red]")
178
196
  raise typer.Exit(1)
179
197
 
180
198
  # Show source file info
181
- console.print(f"[bold]Task: {task_name}[/bold]")
199
+ logger.info(f"[bold]Task: {task_name}[/bold]")
182
200
  if task.source_file:
183
- console.print(f"Source: {task.source_file}\n")
201
+ logger.info(f"Source: {task.source_file}\n")
184
202
 
185
203
  # Create YAML representation
186
204
  task_yaml = {
@@ -202,54 +220,56 @@ def _show_task(task_name: str, tasks_file: Optional[str] = None):
202
220
  # Configure YAML dumper to use literal block style for multiline strings
203
221
  def literal_presenter(dumper, data):
204
222
  """Use literal block style (|) for strings containing newlines."""
205
- if '\n' in data:
206
- return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
207
- return dumper.represent_scalar('tag:yaml.org,2002:str', data)
223
+ if "\n" in data:
224
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
225
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data)
208
226
 
209
227
  yaml.add_representer(str, literal_presenter)
210
228
 
211
229
  # Format and highlight using Rich
212
230
  yaml_str = yaml.dump(task_yaml, default_flow_style=False, sort_keys=False)
213
231
  syntax = Syntax(yaml_str, "yaml", theme="ansi_light", line_numbers=False)
214
- console.print(syntax)
232
+ logger.info(syntax)
215
233
 
216
234
 
217
- 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):
218
236
  """
219
237
  Show dependency tree structure.
220
- @athena: a906cef99324
238
+ @athena: 88fbc03f4915
221
239
  """
222
240
  # Pass task_name as root_task for lazy variable evaluation
223
- recipe = _get_recipe(tasks_file, root_task=task_name)
241
+ recipe = _get_recipe(logger, tasks_file, root_task=task_name)
224
242
  if recipe is None:
225
- console.print("[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]",
245
+ )
226
246
  raise typer.Exit(1)
227
247
 
228
248
  task = recipe.get_task(task_name)
229
249
  if task is None:
230
- console.print(f"[red]Task not found: {task_name}[/red]")
250
+ logger.error(f"[red]Task not found: {task_name}[/red]")
231
251
  raise typer.Exit(1)
232
252
 
233
253
  # Build dependency tree
234
254
  try:
235
255
  dep_tree = build_dependency_tree(recipe, task_name)
236
256
  except Exception as e:
237
- console.print(f"[red]Error building dependency tree: {e}[/red]")
257
+ logger.error(f"[red]Error building dependency tree: {e}[/red]")
238
258
  raise typer.Exit(1)
239
259
 
240
260
  # Build Rich tree
241
261
  tree = _build_rich_tree(dep_tree)
242
- console.print(tree)
262
+ logger.info(tree)
243
263
 
244
264
 
245
- def _init_recipe():
265
+ def _init_recipe(logger: Logger):
246
266
  """
247
267
  Create a blank recipe file with commented examples.
248
- @athena: 189726c9b6c0
268
+ @athena: f05c0eb014d4
249
269
  """
250
270
  recipe_path = Path("tasktree.yaml")
251
271
  if recipe_path.exists():
252
- console.print("[red]tasktree.yaml already exists[/red]")
272
+ logger.error("[red]tasktree.yaml already exists[/red]")
253
273
  raise typer.Exit(1)
254
274
 
255
275
  template = """# Task Tree Recipe
@@ -282,8 +302,8 @@ tasks:
282
302
  """
283
303
 
284
304
  recipe_path.write_text(template)
285
- console.print(f"[green]Created {recipe_path}[/green]")
286
- 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")
287
307
 
288
308
 
289
309
  def _version_callback(value: bool):
@@ -307,31 +327,58 @@ def main(
307
327
  is_eager=True,
308
328
  help="Show version and exit",
309
329
  ),
310
- list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
311
- show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
312
- tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
313
- tasks_file: Optional[str] = typer.Option(None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"),
330
+ list_opt: Optional[bool] = typer.Option(
331
+ None, "--list", "-l", help="List all available tasks"
332
+ ),
333
+ show: Optional[str] = typer.Option(
334
+ None, "--show", "-s", help="Show task definition"
335
+ ),
336
+ tree: Optional[str] = typer.Option(
337
+ None, "--tree", "-t", help="Show dependency tree"
338
+ ),
339
+ tasks_file: Optional[str] = typer.Option(
340
+ None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"
341
+ ),
314
342
  init: Optional[bool] = typer.Option(
315
343
  None, "--init", "-i", help="Create a blank tasktree.yaml"
316
344
  ),
317
345
  clean: Optional[bool] = typer.Option(
318
346
  None, "--clean", "-c", help="Remove state file (reset task cache)"
319
347
  ),
320
- clean_state: Optional[bool] = typer.Option(
321
- None, "--clean-state", "-C", help="Remove state file (reset task cache)"
322
- ),
323
- reset: Optional[bool] = typer.Option(
324
- None, "--reset", "-r", help="Remove state file (reset task cache)"
325
- ),
326
348
  force: Optional[bool] = typer.Option(
327
349
  None, "--force", "-f", help="Force re-run all tasks (ignore freshness)"
328
350
  ),
329
351
  only: Optional[bool] = typer.Option(
330
- None, "--only", "-o", help="Run only the specified task, skip dependencies (implies --force)"
352
+ None,
353
+ "--only",
354
+ "-o",
355
+ help="Run only the specified task, skip dependencies (implies --force)",
331
356
  ),
332
357
  env: Optional[str] = typer.Option(
333
358
  None, "--env", "-e", help="Override environment for all tasks"
334
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
+ ),
335
382
  task_args: Optional[List[str]] = typer.Argument(
336
383
  None, help="Task name and arguments"
337
384
  ),
@@ -348,64 +395,78 @@ def main(
348
395
  tt deploy prod region=us-1 # Run 'deploy' with arguments
349
396
  tt --list # List all tasks
350
397
  tt --tree test # Show dependency tree for 'test'
351
- @athena: f76c75c12d10
398
+ @athena: 40e6fdbe6100
352
399
  """
353
400
 
401
+ logger = ConsoleLogger(console, LogLevel(LogLevel[log_level.upper()]))
402
+
354
403
  if list_opt:
355
- _list_tasks(tasks_file)
404
+ _list_tasks(logger, tasks_file)
356
405
  raise typer.Exit()
357
406
 
358
407
  if show:
359
- _show_task(show, tasks_file)
408
+ _show_task(logger, show, tasks_file)
360
409
  raise typer.Exit()
361
410
 
362
411
  if tree:
363
- _show_tree(tree, tasks_file)
412
+ _show_tree(logger, tree, tasks_file)
364
413
  raise typer.Exit()
365
414
 
366
415
  if init:
367
- _init_recipe()
416
+ _init_recipe(logger)
368
417
  raise typer.Exit()
369
418
 
370
- if clean or clean_state or reset:
371
- _clean_state(tasks_file)
419
+ if clean:
420
+ _clean_state(logger, tasks_file)
372
421
  raise typer.Exit()
373
422
 
374
423
  if task_args:
375
424
  # --only implies --force
376
425
  force_execution = force or only or False
377
- _execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env, tasks_file=tasks_file)
426
+ _execute_dynamic_task(
427
+ logger,
428
+ task_args,
429
+ force=force_execution,
430
+ only=only or False,
431
+ env=env,
432
+ tasks_file=tasks_file,
433
+ task_output=task_output,
434
+ )
378
435
  else:
379
- recipe = _get_recipe(tasks_file)
436
+ recipe = _get_recipe(logger, tasks_file)
380
437
  if recipe is None:
381
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
382
- console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
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",
443
+ )
383
444
  raise typer.Exit(1)
384
445
 
385
- console.print("[bold]Available tasks:[/bold]")
446
+ logger.info("[bold]Available tasks:[/bold]")
386
447
  for task_name in sorted(recipe.task_names()):
387
448
  task = recipe.get_task(task_name)
388
449
  if task and not task.private:
389
- console.print(f" - {task_name}")
390
- console.print("\nUse [cyan]tt --list[/cyan] for detailed information")
391
- 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")
392
453
 
393
454
 
394
- def _clean_state(tasks_file: Optional[str] = None) -> None:
455
+ def _clean_state(logger: Logger, tasks_file: Optional[str] = None) -> None:
395
456
  """
396
457
  Remove the .tasktree-state file to reset task execution state.
397
- @athena: a0ddf4b333d4
458
+ @athena: 2f270f8a2d70
398
459
  """
399
460
  if tasks_file:
400
461
  recipe_path = Path(tasks_file)
401
462
  if not recipe_path.exists():
402
- console.print(f"[red]Recipe file not found: {tasks_file}[/red]")
463
+ logger.error(f"[red]Recipe file not found: {tasks_file}[/red]")
403
464
  raise typer.Exit(1)
404
465
  else:
405
466
  recipe_path = find_recipe_file()
406
467
  if recipe_path is None:
407
- console.print("[yellow]No recipe file found[/yellow]")
408
- 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")
409
470
  raise typer.Exit(1)
410
471
 
411
472
  project_root = recipe_path.parent
@@ -413,26 +474,31 @@ def _clean_state(tasks_file: Optional[str] = None) -> None:
413
474
 
414
475
  if state_path.exists():
415
476
  state_path.unlink()
416
- console.print(f"[green]{get_action_success_string()} Removed {state_path}[/green]")
417
- console.print("All tasks will run fresh on next execution")
477
+ logger.info(
478
+ f"[green]{get_action_success_string()} Removed {state_path}[/green]",
479
+ )
480
+ logger.info("All tasks will run fresh on next execution")
418
481
  else:
419
- 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]")
420
483
 
421
484
 
422
- def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = None) -> Optional[Recipe]:
485
+ def _get_recipe(
486
+ logger: Logger, recipe_file: Optional[str] = None, root_task: Optional[str] = None
487
+ ) -> Optional[Recipe]:
423
488
  """
424
489
  Get parsed recipe or None if not found.
425
490
 
426
491
  Args:
492
+ logger_fn: Logger function for output
427
493
  recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
428
494
  root_task: Optional root task for lazy variable evaluation. If provided, only variables
429
495
  reachable from this task will be evaluated (performance optimization).
430
- @athena: 0ee00c67df25
496
+ @athena: ded906495d18
431
497
  """
432
498
  if recipe_file:
433
499
  recipe_path = Path(recipe_file)
434
500
  if not recipe_path.exists():
435
- console.print(f"[red]Recipe file not found: {recipe_file}[/red]")
501
+ logger.error(f"[red]Recipe file not found: {recipe_file}[/red]")
436
502
  raise typer.Exit(1)
437
503
  # When explicitly specified, project root is current working directory
438
504
  project_root = Path.cwd()
@@ -443,7 +509,7 @@ def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = No
443
509
  return None
444
510
  except ValueError as e:
445
511
  # Multiple recipe files found
446
- console.print(f"[red]{e}[/red]")
512
+ logger.error(f"[red]{e}[/red]")
447
513
  raise typer.Exit(1)
448
514
  # When auto-discovered, project root is recipe file's parent
449
515
  project_root = None
@@ -451,22 +517,32 @@ def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = No
451
517
  try:
452
518
  return parse_recipe(recipe_path, project_root, root_task)
453
519
  except Exception as e:
454
- console.print(f"[red]Error parsing recipe: {e}[/red]")
520
+ logger.error(f"[red]Error parsing recipe: {e}[/red]")
455
521
  raise typer.Exit(1)
456
522
 
457
523
 
458
- def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None, tasks_file: Optional[str] = None) -> None:
524
+ def _execute_dynamic_task(
525
+ logger: Logger,
526
+ args: list[str],
527
+ force: bool = False,
528
+ only: bool = False,
529
+ env: Optional[str] = None,
530
+ tasks_file: Optional[str] = None,
531
+ task_output: str | None = None,
532
+ ) -> None:
459
533
  """
460
534
  Execute a task with its dependencies and handle argument parsing.
461
535
 
462
536
  Args:
463
- args: Task name followed by optional task arguments
464
- force: Force re-execution even if task is up-to-date
465
- only: Execute only the specified task, skip dependencies
466
- env: Override environment for task execution
467
- 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)
468
544
 
469
- @athena: 207f7635a60d
545
+ @athena: 36ae914a5bc7
470
546
  """
471
547
  if not args:
472
548
  return
@@ -475,39 +551,41 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
475
551
  task_args = args[1:]
476
552
 
477
553
  # Pass task_name as root_task for lazy variable evaluation
478
- recipe = _get_recipe(tasks_file, root_task=task_name)
554
+ recipe = _get_recipe(logger, tasks_file, root_task=task_name)
479
555
  if recipe is None:
480
- console.print("[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]",
558
+ )
481
559
  raise typer.Exit(1)
482
560
 
483
561
  # Apply global environment override if provided
484
562
  if env:
485
563
  # Validate that the environment exists
486
564
  if not recipe.get_environment(env):
487
- console.print(f"[red]Environment not found: {env}[/red]")
488
- console.print("\nAvailable environments:")
565
+ logger.error(f"[red]Environment not found: {env}[/red]")
566
+ logger.info("\nAvailable environments:")
489
567
  for env_name in sorted(recipe.environments.keys()):
490
- console.print(f" - {env_name}")
568
+ logger.info(f" - {env_name}")
491
569
  raise typer.Exit(1)
492
570
  recipe.global_env_override = env
493
571
 
494
572
  task = recipe.get_task(task_name)
495
573
  if task is None:
496
- console.print(f"[red]Task not found: {task_name}[/red]")
497
- console.print("\nAvailable tasks:")
574
+ logger.error(f"[red]Task not found: {task_name}[/red]")
575
+ logger.info("\nAvailable tasks:")
498
576
  for name in sorted(recipe.task_names()):
499
577
  task = recipe.get_task(name)
500
578
  if task and not task.private:
501
- console.print(f" - {name}")
579
+ logger.info(f" - {name}")
502
580
  raise typer.Exit(1)
503
581
 
504
582
  # Parse task arguments
505
- args_dict = _parse_task_args(task.args, task_args)
583
+ args_dict = _parse_task_args(logger, task.args, task_args)
506
584
 
507
585
  # Create executor and state manager
508
586
  state = StateManager(recipe.project_root)
509
587
  state.load()
510
- executor = Executor(recipe, state)
588
+ executor = Executor(recipe, state, logger, make_process_runner)
511
589
 
512
590
  # Resolve execution order to determine which tasks will actually run
513
591
  # This is important for correct state pruning after template substitution
@@ -522,14 +600,13 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
522
600
  # This substitutes {{ self.inputs.* }} and {{ self.outputs.* }} templates
523
601
  resolve_self_references(recipe, execution_order)
524
602
  except ValueError as e:
525
- console.print(f"[red]Error in task template: {e}[/red]")
603
+ logger.error(f"[red]Error in task template: {e}[/red]")
526
604
  raise typer.Exit(1)
527
605
 
528
606
  # Prune state based on tasks that will actually execute (with their specific arguments)
529
607
  # This ensures template-substituted dependencies are handled correctly
530
608
  valid_hashes = set()
531
- for exec_task_name, exec_task_args in execution_order:
532
- task = recipe.tasks[exec_task_name]
609
+ for _, task in recipe.tasks.items():
533
610
  # Compute base task hash
534
611
  task_hash = hash_task(
535
612
  task.cmd,
@@ -537,47 +614,53 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
537
614
  task.working_dir,
538
615
  task.args,
539
616
  executor._get_effective_env_name(task),
540
- task.deps
617
+ task.deps,
541
618
  )
542
619
 
543
- # If task has arguments, append args hash to create unique cache key
544
- if exec_task_args:
545
- args_hash = hash_args(exec_task_args)
546
- cache_key = f"{task_hash}__{args_hash}"
547
- else:
548
- cache_key = task_hash
549
-
550
- valid_hashes.add(cache_key)
620
+ valid_hashes.add(task_hash)
551
621
 
552
622
  state.prune(valid_hashes)
553
623
  state.save()
554
624
  try:
555
- executor.execute_task(task_name, args_dict, force=force, only=only)
556
- console.print(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]",
634
+ )
557
635
  except Exception as e:
558
- console.print(f"[red]{get_action_failure_string()} Task '{task_name}' failed: {e}[/red]")
636
+ logger.error(
637
+ f"[red]{get_action_failure_string()} Task '{task_name}' failed: {e}[/red]"
638
+ )
559
639
  raise typer.Exit(1)
560
640
 
561
641
 
562
- 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]:
563
645
  """
564
646
  Parse and validate task arguments from command line values.
565
647
 
566
648
  Args:
567
- arg_specs: Task argument specifications with types and defaults
568
- 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)
569
652
 
570
653
  Returns:
571
- Dictionary mapping argument names to typed, validated values
654
+ Dictionary mapping argument names to typed, validated values
572
655
 
573
656
  Raises:
574
- typer.Exit: If arguments are invalid, missing, or unknown
657
+ typer.Exit: If arguments are invalid, missing, or unknown
575
658
 
576
- @athena: 2072a35f9d11
659
+ @athena: d9a7ea55c3d6
577
660
  """
578
661
  if not arg_specs:
579
662
  if arg_values:
580
- console.print(f"[red]Task does not accept arguments[/red]")
663
+ logger.error("[red]Task does not accept arguments[/red]")
581
664
  raise typer.Exit(1)
582
665
  return {}
583
666
 
@@ -596,12 +679,12 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
596
679
  # Find the spec for this argument
597
680
  spec = next((s for s in parsed_specs if s.name == arg_name), None)
598
681
  if spec is None:
599
- console.print(f"[red]Unknown argument: {arg_name}[/red]")
682
+ logger.error(f"[red]Unknown argument: {arg_name}[/red]")
600
683
  raise typer.Exit(1)
601
684
  else:
602
685
  # Positional argument
603
686
  if positional_index >= len(parsed_specs):
604
- console.print(f"[red]Too many arguments[/red]")
687
+ logger.error("[red]Too many arguments[/red]")
605
688
  raise typer.Exit(1)
606
689
  spec = parsed_specs[positional_index]
607
690
  arg_value = value_str
@@ -609,20 +692,26 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
609
692
 
610
693
  # Convert value to appropriate type (exported args are always strings)
611
694
  try:
612
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
695
+ click_type = get_click_type(
696
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
697
+ )
613
698
  converted_value = click_type.convert(arg_value, None, None)
614
699
 
615
700
  # Validate choices after type conversion
616
701
  if spec.choices is not None and converted_value not in spec.choices:
617
- console.print(f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]")
618
- console.print(f"Valid choices: {', '.join(repr(c) for c in spec.choices)}")
702
+ logger.error(
703
+ f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]",
704
+ )
705
+ logger.info(
706
+ f"Valid choices: {', '.join(repr(c) for c in spec.choices)}",
707
+ )
619
708
  raise typer.Exit(1)
620
709
 
621
710
  args_dict[spec.name] = converted_value
622
711
  except typer.Exit:
623
712
  raise # Re-raise typer.Exit without wrapping
624
713
  except Exception as e:
625
- console.print(f"[red]Invalid value for {spec.name}: {e}[/red]")
714
+ logger.error(f"[red]Invalid value for {spec.name}: {e}[/red]")
626
715
  raise typer.Exit(1)
627
716
 
628
717
  # Fill in defaults for missing arguments
@@ -630,13 +719,17 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
630
719
  if spec.name not in args_dict:
631
720
  if spec.default is not None:
632
721
  try:
633
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
722
+ click_type = get_click_type(
723
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
724
+ )
634
725
  args_dict[spec.name] = click_type.convert(spec.default, None, None)
635
726
  except Exception as e:
636
- console.print(f"[red]Invalid default value for {spec.name}: {e}[/red]")
727
+ logger.error(
728
+ f"[red]Invalid default value for {spec.name}: {e}[/red]",
729
+ )
637
730
  raise typer.Exit(1)
638
731
  else:
639
- console.print(f"[red]Missing required argument: {spec.name}[/red]")
732
+ logger.error(f"[red]Missing required argument: {spec.name}[/red]")
640
733
  raise typer.Exit(1)
641
734
 
642
735
  return args_dict