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 +1 -1
- tasktree/cli.py +212 -119
- tasktree/console_logger.py +66 -0
- tasktree/docker.py +36 -23
- tasktree/executor.py +412 -240
- tasktree/graph.py +18 -13
- tasktree/hasher.py +18 -11
- tasktree/logging.py +112 -0
- tasktree/parser.py +237 -135
- tasktree/process_runner.py +411 -0
- tasktree/state.py +7 -8
- tasktree/substitution.py +29 -17
- tasktree/types.py +32 -15
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/METADATA +213 -18
- tasktree-0.0.23.dist-info/RECORD +17 -0
- tasktree-0.0.21.dist-info/RECORD +0 -14
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/WHEEL +0 -0
- {tasktree-0.0.21.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
|
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
+
logger.info(f"[bold]Task: {task_name}[/bold]")
|
|
182
200
|
if task.source_file:
|
|
183
|
-
|
|
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
|
|
206
|
-
return dumper.represent_scalar(
|
|
207
|
-
return dumper.represent_scalar(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
268
|
+
@athena: f05c0eb014d4
|
|
249
269
|
"""
|
|
250
270
|
recipe_path = Path("tasktree.yaml")
|
|
251
271
|
if recipe_path.exists():
|
|
252
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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(
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
482
|
+
logger.info(f"[yellow]No state file found at {state_path}[/yellow]")
|
|
420
483
|
|
|
421
484
|
|
|
422
|
-
def _get_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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
556
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
654
|
+
Dictionary mapping argument names to typed, validated values
|
|
572
655
|
|
|
573
656
|
Raises:
|
|
574
|
-
|
|
657
|
+
typer.Exit: If arguments are invalid, missing, or unknown
|
|
575
658
|
|
|
576
|
-
@athena:
|
|
659
|
+
@athena: d9a7ea55c3d6
|
|
577
660
|
"""
|
|
578
661
|
if not arg_specs:
|
|
579
662
|
if arg_values:
|
|
580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
732
|
+
logger.error(f"[red]Missing required argument: {spec.name}[/red]")
|
|
640
733
|
raise typer.Exit(1)
|
|
641
734
|
|
|
642
735
|
return args_dict
|