tasktree 0.0.2__py3-none-any.whl → 0.0.4__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/cli.py +43 -138
- tasktree/executor.py +202 -16
- tasktree/hasher.py +6 -53
- tasktree/parser.py +108 -5
- tasktree/types.py +7 -7
- {tasktree-0.0.2.dist-info → tasktree-0.0.4.dist-info}/METADATA +251 -14
- tasktree-0.0.4.dist-info/RECORD +13 -0
- tasktree-0.0.2.dist-info/RECORD +0 -13
- {tasktree-0.0.2.dist-info → tasktree-0.0.4.dist-info}/WHEEL +0 -0
- {tasktree-0.0.2.dist-info → tasktree-0.0.4.dist-info}/entry_points.txt +0 -0
tasktree/cli.py
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
"""Command-line interface for Task Tree."""
|
|
2
|
-
|
|
3
1
|
from __future__ import annotations
|
|
4
2
|
|
|
5
|
-
import sys
|
|
6
3
|
from pathlib import Path
|
|
7
4
|
from typing import Any, List, Optional
|
|
8
5
|
|
|
@@ -89,7 +86,7 @@ def _show_task(task_name: str):
|
|
|
89
86
|
|
|
90
87
|
|
|
91
88
|
def _show_tree(task_name: str):
|
|
92
|
-
"""Show dependency tree
|
|
89
|
+
"""Show dependency tree structure."""
|
|
93
90
|
recipe = _get_recipe()
|
|
94
91
|
if recipe is None:
|
|
95
92
|
console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
|
|
@@ -107,59 +104,11 @@ def _show_tree(task_name: str):
|
|
|
107
104
|
console.print(f"[red]Error building dependency tree: {e}[/red]")
|
|
108
105
|
raise typer.Exit(1)
|
|
109
106
|
|
|
110
|
-
# Get execution statuses
|
|
111
|
-
state = StateManager(recipe.project_root)
|
|
112
|
-
state.load()
|
|
113
|
-
executor = Executor(recipe, state)
|
|
114
|
-
statuses = executor.execute_task(task_name, dry_run=True)
|
|
115
|
-
|
|
116
107
|
# Build Rich tree
|
|
117
|
-
tree = _build_rich_tree(dep_tree
|
|
108
|
+
tree = _build_rich_tree(dep_tree)
|
|
118
109
|
console.print(tree)
|
|
119
110
|
|
|
120
111
|
|
|
121
|
-
def _dry_run(task_name: str):
|
|
122
|
-
"""Show what would be executed without actually running."""
|
|
123
|
-
recipe = _get_recipe()
|
|
124
|
-
if recipe is None:
|
|
125
|
-
console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
|
|
126
|
-
raise typer.Exit(1)
|
|
127
|
-
|
|
128
|
-
task = recipe.get_task(task_name)
|
|
129
|
-
if task is None:
|
|
130
|
-
console.print(f"[red]Task not found: {task_name}[/red]")
|
|
131
|
-
raise typer.Exit(1)
|
|
132
|
-
|
|
133
|
-
# Get execution plan
|
|
134
|
-
state = StateManager(recipe.project_root)
|
|
135
|
-
state.load()
|
|
136
|
-
executor = Executor(recipe, state)
|
|
137
|
-
statuses = executor.execute_task(task_name, dry_run=True)
|
|
138
|
-
|
|
139
|
-
# Display plan
|
|
140
|
-
console.print(f"[bold]Execution plan for '{task_name}':[/bold]\n")
|
|
141
|
-
|
|
142
|
-
will_run = [name for name, status in statuses.items() if status.will_run]
|
|
143
|
-
will_skip = [name for name, status in statuses.items() if not status.will_run]
|
|
144
|
-
|
|
145
|
-
if will_run:
|
|
146
|
-
console.print(f"[yellow]Will execute ({len(will_run)} tasks):[/yellow]")
|
|
147
|
-
for i, name in enumerate(will_run, 1):
|
|
148
|
-
status = statuses[name]
|
|
149
|
-
console.print(f" {i}. [cyan]{name}[/cyan]")
|
|
150
|
-
console.print(f" - {status.reason}")
|
|
151
|
-
if status.changed_files:
|
|
152
|
-
console.print(f" - changed files: {', '.join(status.changed_files)}")
|
|
153
|
-
console.print()
|
|
154
|
-
|
|
155
|
-
if will_skip:
|
|
156
|
-
console.print(f"[green]Will skip ({len(will_skip)} tasks):[/green]")
|
|
157
|
-
for name in will_skip:
|
|
158
|
-
status = statuses[name]
|
|
159
|
-
last_run_str = f", last run {status.last_run}" if status.last_run else ""
|
|
160
|
-
console.print(f" - {name} (fresh{last_run_str})")
|
|
161
|
-
|
|
162
|
-
|
|
163
112
|
def _init_recipe():
|
|
164
113
|
"""Create a blank recipe file with commented examples."""
|
|
165
114
|
recipe_path = Path("tasktree.yaml")
|
|
@@ -168,7 +117,7 @@ def _init_recipe():
|
|
|
168
117
|
raise typer.Exit(1)
|
|
169
118
|
|
|
170
119
|
template = """# Task Tree Recipe
|
|
171
|
-
# See https://github.com/kevinchannon/
|
|
120
|
+
# See https://github.com/kevinchannon/task-tree for documentation
|
|
172
121
|
|
|
173
122
|
# Example task definitions:
|
|
174
123
|
|
|
@@ -216,29 +165,30 @@ def main(
|
|
|
216
165
|
is_eager=True,
|
|
217
166
|
help="Show version and exit",
|
|
218
167
|
),
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
),
|
|
222
|
-
show: Optional[str] = typer.Option(None, "--show", help="Show task definition"),
|
|
223
|
-
tree: Optional[str] = typer.Option(None, "--tree", help="Show dependency tree"),
|
|
224
|
-
dry_run: Optional[str] = typer.Option(
|
|
225
|
-
None, "--dry-run", help="Show execution plan without running"
|
|
226
|
-
),
|
|
168
|
+
list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
|
|
169
|
+
show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
|
|
170
|
+
tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
|
|
227
171
|
init: Optional[bool] = typer.Option(
|
|
228
|
-
None, "--init", help="Create a blank tasktree.yaml"
|
|
172
|
+
None, "--init", "-i", help="Create a blank tasktree.yaml"
|
|
229
173
|
),
|
|
230
174
|
clean: Optional[bool] = typer.Option(
|
|
231
|
-
None, "--clean", help="Remove state file (reset task cache)"
|
|
175
|
+
None, "--clean", "-c", help="Remove state file (reset task cache)"
|
|
232
176
|
),
|
|
233
177
|
clean_state: Optional[bool] = typer.Option(
|
|
234
|
-
None, "--clean-state", help="Remove state file (reset task cache)"
|
|
178
|
+
None, "--clean-state", "-C", help="Remove state file (reset task cache)"
|
|
235
179
|
),
|
|
236
180
|
reset: Optional[bool] = typer.Option(
|
|
237
|
-
None, "--reset", help="Remove state file (reset task cache)"
|
|
181
|
+
None, "--reset", "-r", help="Remove state file (reset task cache)"
|
|
238
182
|
),
|
|
239
183
|
force: Optional[bool] = typer.Option(
|
|
240
184
|
None, "--force", "-f", help="Force re-run all tasks (ignore freshness)"
|
|
241
185
|
),
|
|
186
|
+
only: Optional[bool] = typer.Option(
|
|
187
|
+
None, "--only", "-o", help="Run only the specified task, skip dependencies (implies --force)"
|
|
188
|
+
),
|
|
189
|
+
env: Optional[str] = typer.Option(
|
|
190
|
+
None, "--env", "-e", help="Override environment for all tasks"
|
|
191
|
+
),
|
|
242
192
|
task_args: Optional[List[str]] = typer.Argument(
|
|
243
193
|
None, help="Task name and arguments"
|
|
244
194
|
),
|
|
@@ -255,41 +205,32 @@ def main(
|
|
|
255
205
|
tt --list # List all tasks
|
|
256
206
|
tt --tree test # Show dependency tree for 'test'
|
|
257
207
|
"""
|
|
258
|
-
|
|
259
|
-
if
|
|
208
|
+
|
|
209
|
+
if list_opt:
|
|
260
210
|
_list_tasks()
|
|
261
211
|
raise typer.Exit()
|
|
262
212
|
|
|
263
|
-
# Handle show option
|
|
264
213
|
if show:
|
|
265
214
|
_show_task(show)
|
|
266
215
|
raise typer.Exit()
|
|
267
216
|
|
|
268
|
-
# Handle tree option
|
|
269
217
|
if tree:
|
|
270
218
|
_show_tree(tree)
|
|
271
219
|
raise typer.Exit()
|
|
272
220
|
|
|
273
|
-
# Handle dry-run option
|
|
274
|
-
if dry_run:
|
|
275
|
-
_dry_run(dry_run)
|
|
276
|
-
raise typer.Exit()
|
|
277
|
-
|
|
278
|
-
# Handle init option
|
|
279
221
|
if init:
|
|
280
222
|
_init_recipe()
|
|
281
223
|
raise typer.Exit()
|
|
282
224
|
|
|
283
|
-
# Handle clean options (all three aliases)
|
|
284
225
|
if clean or clean_state or reset:
|
|
285
226
|
_clean_state()
|
|
286
227
|
raise typer.Exit()
|
|
287
228
|
|
|
288
|
-
# Handle task execution
|
|
289
229
|
if task_args:
|
|
290
|
-
|
|
230
|
+
# --only implies --force
|
|
231
|
+
force_execution = force or only or False
|
|
232
|
+
_execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env)
|
|
291
233
|
else:
|
|
292
|
-
# No arguments - show available tasks
|
|
293
234
|
recipe = _get_recipe()
|
|
294
235
|
if recipe is None:
|
|
295
236
|
console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
|
|
@@ -322,7 +263,7 @@ def _clean_state() -> None:
|
|
|
322
263
|
console.print(f"[yellow]No state file found at {state_path}[/yellow]")
|
|
323
264
|
|
|
324
265
|
|
|
325
|
-
def _get_recipe() -> Recipe
|
|
266
|
+
def _get_recipe() -> Optional[Recipe]:
|
|
326
267
|
"""Get parsed recipe or None if not found."""
|
|
327
268
|
recipe_path = find_recipe_file()
|
|
328
269
|
if recipe_path is None:
|
|
@@ -335,13 +276,7 @@ def _get_recipe() -> Recipe | None:
|
|
|
335
276
|
raise typer.Exit(1)
|
|
336
277
|
|
|
337
278
|
|
|
338
|
-
def _execute_dynamic_task(args: list[str], force: bool = False) -> None:
|
|
339
|
-
"""Execute a task specified by name with arguments.
|
|
340
|
-
|
|
341
|
-
Args:
|
|
342
|
-
args: Command line arguments (task name and task arguments)
|
|
343
|
-
force: If True, ignore freshness and re-run all tasks
|
|
344
|
-
"""
|
|
279
|
+
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None) -> None:
|
|
345
280
|
if not args:
|
|
346
281
|
return
|
|
347
282
|
|
|
@@ -353,6 +288,17 @@ def _execute_dynamic_task(args: list[str], force: bool = False) -> None:
|
|
|
353
288
|
console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
|
|
354
289
|
raise typer.Exit(1)
|
|
355
290
|
|
|
291
|
+
# Apply global environment override if provided
|
|
292
|
+
if env:
|
|
293
|
+
# Validate that the environment exists
|
|
294
|
+
if not recipe.get_environment(env):
|
|
295
|
+
console.print(f"[red]Environment not found: {env}[/red]")
|
|
296
|
+
console.print("\nAvailable environments:")
|
|
297
|
+
for env_name in sorted(recipe.environments.keys()):
|
|
298
|
+
console.print(f" - {env_name}")
|
|
299
|
+
raise typer.Exit(1)
|
|
300
|
+
recipe.global_env_override = env
|
|
301
|
+
|
|
356
302
|
task = recipe.get_task(task_name)
|
|
357
303
|
if task is None:
|
|
358
304
|
console.print(f"[red]Task not found: {task_name}[/red]")
|
|
@@ -364,20 +310,20 @@ def _execute_dynamic_task(args: list[str], force: bool = False) -> None:
|
|
|
364
310
|
# Parse task arguments
|
|
365
311
|
args_dict = _parse_task_args(task.args, task_args)
|
|
366
312
|
|
|
367
|
-
#
|
|
313
|
+
# Create executor and state manager
|
|
368
314
|
state = StateManager(recipe.project_root)
|
|
369
315
|
state.load()
|
|
316
|
+
executor = Executor(recipe, state)
|
|
317
|
+
|
|
318
|
+
# Prune state before execution (compute hashes with effective environment)
|
|
370
319
|
valid_hashes = {
|
|
371
|
-
hash_task(t.cmd, t.outputs, t.working_dir, t.args)
|
|
320
|
+
hash_task(t.cmd, t.outputs, t.working_dir, t.args, executor._get_effective_env_name(t))
|
|
372
321
|
for t in recipe.tasks.values()
|
|
373
322
|
}
|
|
374
323
|
state.prune(valid_hashes)
|
|
375
324
|
state.save()
|
|
376
|
-
|
|
377
|
-
# Execute task
|
|
378
|
-
executor = Executor(recipe, state)
|
|
379
325
|
try:
|
|
380
|
-
executor.execute_task(task_name, args_dict,
|
|
326
|
+
executor.execute_task(task_name, args_dict, force=force, only=only)
|
|
381
327
|
console.print(f"[green]✓ Task '{task_name}' completed successfully[/green]")
|
|
382
328
|
except Exception as e:
|
|
383
329
|
console.print(f"[red]✗ Task '{task_name}' failed: {e}[/red]")
|
|
@@ -385,31 +331,17 @@ def _execute_dynamic_task(args: list[str], force: bool = False) -> None:
|
|
|
385
331
|
|
|
386
332
|
|
|
387
333
|
def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, Any]:
|
|
388
|
-
"""Parse command line arguments for a task.
|
|
389
|
-
|
|
390
|
-
Args:
|
|
391
|
-
arg_specs: Argument specifications from task definition
|
|
392
|
-
arg_values: Command line argument values
|
|
393
|
-
|
|
394
|
-
Returns:
|
|
395
|
-
Dictionary of argument names to values
|
|
396
|
-
|
|
397
|
-
Raises:
|
|
398
|
-
typer.Exit: If arguments are invalid
|
|
399
|
-
"""
|
|
400
334
|
if not arg_specs:
|
|
401
335
|
if arg_values:
|
|
402
336
|
console.print(f"[red]Task does not accept arguments[/red]")
|
|
403
337
|
raise typer.Exit(1)
|
|
404
338
|
return {}
|
|
405
339
|
|
|
406
|
-
# Parse argument specifications
|
|
407
340
|
parsed_specs = []
|
|
408
341
|
for spec in arg_specs:
|
|
409
342
|
name, arg_type, default = parse_arg_spec(spec)
|
|
410
343
|
parsed_specs.append((name, arg_type, default))
|
|
411
344
|
|
|
412
|
-
# Build argument dictionary
|
|
413
345
|
args_dict = {}
|
|
414
346
|
positional_index = 0
|
|
415
347
|
|
|
@@ -458,40 +390,13 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
458
390
|
return args_dict
|
|
459
391
|
|
|
460
392
|
|
|
461
|
-
def _build_rich_tree(dep_tree: dict
|
|
462
|
-
"""Build a Rich Tree from dependency tree and statuses.
|
|
463
|
-
|
|
464
|
-
Args:
|
|
465
|
-
dep_tree: Dependency tree structure
|
|
466
|
-
statuses: Task execution statuses
|
|
467
|
-
|
|
468
|
-
Returns:
|
|
469
|
-
Rich Tree for display
|
|
470
|
-
"""
|
|
393
|
+
def _build_rich_tree(dep_tree: dict) -> Tree:
|
|
471
394
|
task_name = dep_tree["name"]
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
# Determine color based on status
|
|
475
|
-
if status:
|
|
476
|
-
if status.will_run:
|
|
477
|
-
if status.reason == "dependency_triggered":
|
|
478
|
-
color = "yellow"
|
|
479
|
-
label = f"{task_name} (triggered by dependency)"
|
|
480
|
-
else:
|
|
481
|
-
color = "red"
|
|
482
|
-
label = f"{task_name} (stale: {status.reason})"
|
|
483
|
-
else:
|
|
484
|
-
color = "green"
|
|
485
|
-
label = f"{task_name} (fresh)"
|
|
486
|
-
else:
|
|
487
|
-
color = "white"
|
|
488
|
-
label = task_name
|
|
489
|
-
|
|
490
|
-
tree = Tree(f"[{color}]{label}[/{color}]")
|
|
395
|
+
tree = Tree(task_name)
|
|
491
396
|
|
|
492
397
|
# Add dependencies
|
|
493
398
|
for dep in dep_tree.get("deps", []):
|
|
494
|
-
dep_tree_obj = _build_rich_tree(dep
|
|
399
|
+
dep_tree_obj = _build_rich_tree(dep)
|
|
495
400
|
tree.add(dep_tree_obj)
|
|
496
401
|
|
|
497
402
|
return tree
|
tasktree/executor.py
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import stat
|
|
5
8
|
import subprocess
|
|
9
|
+
import tempfile
|
|
6
10
|
import time
|
|
7
11
|
from dataclasses import dataclass, field
|
|
8
12
|
from datetime import datetime
|
|
@@ -46,6 +50,85 @@ class Executor:
|
|
|
46
50
|
self.recipe = recipe
|
|
47
51
|
self.state = state_manager
|
|
48
52
|
|
|
53
|
+
def _get_platform_default_environment(self) -> tuple[str, list[str]]:
|
|
54
|
+
"""Get default shell and args for current platform.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (shell, args) for platform default
|
|
58
|
+
"""
|
|
59
|
+
is_windows = platform.system() == "Windows"
|
|
60
|
+
if is_windows:
|
|
61
|
+
return ("cmd", ["/c"])
|
|
62
|
+
else:
|
|
63
|
+
return ("bash", ["-c"])
|
|
64
|
+
|
|
65
|
+
def _get_effective_env_name(self, task: Task) -> str:
|
|
66
|
+
"""Get the effective environment name for a task.
|
|
67
|
+
|
|
68
|
+
Resolution order:
|
|
69
|
+
1. Recipe's global_env_override (from CLI --env)
|
|
70
|
+
2. Task's explicit env field
|
|
71
|
+
3. Recipe's default_env
|
|
72
|
+
4. Empty string (for platform default)
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
task: Task to get environment name for
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Environment name (empty string if using platform default)
|
|
79
|
+
"""
|
|
80
|
+
# Check for global override first
|
|
81
|
+
if self.recipe.global_env_override:
|
|
82
|
+
return self.recipe.global_env_override
|
|
83
|
+
|
|
84
|
+
# Use task's env
|
|
85
|
+
if task.env:
|
|
86
|
+
return task.env
|
|
87
|
+
|
|
88
|
+
# Use recipe default
|
|
89
|
+
if self.recipe.default_env:
|
|
90
|
+
return self.recipe.default_env
|
|
91
|
+
|
|
92
|
+
# Platform default (no env name)
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
def _resolve_environment(self, task: Task) -> tuple[str, list[str], str]:
|
|
96
|
+
"""Resolve which environment to use for a task.
|
|
97
|
+
|
|
98
|
+
Resolution order:
|
|
99
|
+
1. Recipe's global_env_override (from CLI --env)
|
|
100
|
+
2. Task's explicit env field
|
|
101
|
+
3. Recipe's default_env
|
|
102
|
+
4. Platform default (bash on Unix, cmd on Windows)
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
task: Task to resolve environment for
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Tuple of (shell, args, preamble)
|
|
109
|
+
"""
|
|
110
|
+
# Check for global override first
|
|
111
|
+
env_name = self.recipe.global_env_override
|
|
112
|
+
|
|
113
|
+
# If no global override, use task's env
|
|
114
|
+
if not env_name:
|
|
115
|
+
env_name = task.env
|
|
116
|
+
|
|
117
|
+
# If no explicit env, try recipe default
|
|
118
|
+
if not env_name and self.recipe.default_env:
|
|
119
|
+
env_name = self.recipe.default_env
|
|
120
|
+
|
|
121
|
+
# If we have an env name, look it up
|
|
122
|
+
if env_name:
|
|
123
|
+
env = self.recipe.get_environment(env_name)
|
|
124
|
+
if env:
|
|
125
|
+
return (env.shell, env.args, env.preamble)
|
|
126
|
+
# If env not found, fall through to platform default
|
|
127
|
+
|
|
128
|
+
# Use platform default
|
|
129
|
+
shell, args = self._get_platform_default_environment()
|
|
130
|
+
return (shell, args, "")
|
|
131
|
+
|
|
49
132
|
def check_task_status(
|
|
50
133
|
self,
|
|
51
134
|
task: Task,
|
|
@@ -81,8 +164,9 @@ class Executor:
|
|
|
81
164
|
reason="forced",
|
|
82
165
|
)
|
|
83
166
|
|
|
84
|
-
# Compute hashes
|
|
85
|
-
|
|
167
|
+
# Compute hashes (include effective environment)
|
|
168
|
+
effective_env = self._get_effective_env_name(task)
|
|
169
|
+
task_hash = hash_task(task.cmd, task.outputs, task.working_dir, task.args, effective_env)
|
|
86
170
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
87
171
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
88
172
|
|
|
@@ -146,16 +230,16 @@ class Executor:
|
|
|
146
230
|
self,
|
|
147
231
|
task_name: str,
|
|
148
232
|
args_dict: dict[str, Any] | None = None,
|
|
149
|
-
dry_run: bool = False,
|
|
150
233
|
force: bool = False,
|
|
234
|
+
only: bool = False,
|
|
151
235
|
) -> dict[str, TaskStatus]:
|
|
152
236
|
"""Execute a task and its dependencies.
|
|
153
237
|
|
|
154
238
|
Args:
|
|
155
239
|
task_name: Name of task to execute
|
|
156
240
|
args_dict: Arguments to pass to the task
|
|
157
|
-
dry_run: If True, only check what would run without executing
|
|
158
241
|
force: If True, ignore freshness and re-run all tasks
|
|
242
|
+
only: If True, run only the specified task without dependencies (implies force=True)
|
|
159
243
|
|
|
160
244
|
Returns:
|
|
161
245
|
Dictionary of task names to their execution status
|
|
@@ -166,8 +250,17 @@ class Executor:
|
|
|
166
250
|
if args_dict is None:
|
|
167
251
|
args_dict = {}
|
|
168
252
|
|
|
253
|
+
# When only=True, force execution (ignore freshness)
|
|
254
|
+
if only:
|
|
255
|
+
force = True
|
|
256
|
+
|
|
169
257
|
# Resolve execution order
|
|
170
|
-
|
|
258
|
+
if only:
|
|
259
|
+
# Only execute the target task, skip dependencies
|
|
260
|
+
execution_order = [task_name]
|
|
261
|
+
else:
|
|
262
|
+
# Execute task and all dependencies
|
|
263
|
+
execution_order = resolve_execution_order(self.recipe, task_name)
|
|
171
264
|
|
|
172
265
|
# Check status of all tasks
|
|
173
266
|
statuses: dict[str, TaskStatus] = {}
|
|
@@ -183,9 +276,6 @@ class Executor:
|
|
|
183
276
|
status = self.check_task_status(task, task_args, dep_statuses, force=force)
|
|
184
277
|
statuses[name] = status
|
|
185
278
|
|
|
186
|
-
if dry_run:
|
|
187
|
-
return statuses
|
|
188
|
-
|
|
189
279
|
# Execute tasks that need to run
|
|
190
280
|
for name in execution_order:
|
|
191
281
|
status = statuses[name]
|
|
@@ -220,21 +310,116 @@ class Executor:
|
|
|
220
310
|
# Determine working directory
|
|
221
311
|
working_dir = self.recipe.project_root / task.working_dir
|
|
222
312
|
|
|
313
|
+
# Resolve environment for this task
|
|
314
|
+
shell, shell_args, preamble = self._resolve_environment(task)
|
|
315
|
+
|
|
223
316
|
# Execute command
|
|
224
317
|
print(f"Running: {task.name}")
|
|
318
|
+
|
|
319
|
+
# Detect multi-line commands (ignore trailing newlines from YAML folded blocks)
|
|
320
|
+
if "\n" in cmd.rstrip():
|
|
321
|
+
self._run_multiline_command(cmd, working_dir, task.name, shell, preamble)
|
|
322
|
+
else:
|
|
323
|
+
self._run_single_line_command(cmd, working_dir, task.name, shell, shell_args)
|
|
324
|
+
|
|
325
|
+
# Update state
|
|
326
|
+
self._update_state(task, args_dict)
|
|
327
|
+
|
|
328
|
+
def _run_single_line_command(
|
|
329
|
+
self, cmd: str, working_dir: Path, task_name: str, shell: str, shell_args: list[str]
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Execute a single-line command via shell.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
cmd: Command string
|
|
335
|
+
working_dir: Working directory
|
|
336
|
+
task_name: Task name (for error messages)
|
|
337
|
+
shell: Shell executable to use
|
|
338
|
+
shell_args: Arguments to pass to shell
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ExecutionError: If command execution fails
|
|
342
|
+
"""
|
|
225
343
|
try:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
344
|
+
# Build command: shell + args + cmd
|
|
345
|
+
full_cmd = [shell] + shell_args + [cmd]
|
|
346
|
+
subprocess.run(
|
|
347
|
+
full_cmd,
|
|
229
348
|
cwd=working_dir,
|
|
230
349
|
check=True,
|
|
231
350
|
capture_output=False,
|
|
232
351
|
)
|
|
233
352
|
except subprocess.CalledProcessError as e:
|
|
234
|
-
raise ExecutionError(
|
|
353
|
+
raise ExecutionError(
|
|
354
|
+
f"Task '{task_name}' failed with exit code {e.returncode}"
|
|
355
|
+
)
|
|
235
356
|
|
|
236
|
-
|
|
237
|
-
self
|
|
357
|
+
def _run_multiline_command(
|
|
358
|
+
self, cmd: str, working_dir: Path, task_name: str, shell: str, preamble: str
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Execute a multi-line command via temporary script file.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
cmd: Multi-line command string
|
|
364
|
+
working_dir: Working directory
|
|
365
|
+
task_name: Task name (for error messages)
|
|
366
|
+
shell: Shell to use for script execution
|
|
367
|
+
preamble: Preamble text to prepend to script
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
ExecutionError: If command execution fails
|
|
371
|
+
"""
|
|
372
|
+
# Determine file extension based on platform
|
|
373
|
+
is_windows = platform.system() == "Windows"
|
|
374
|
+
script_ext = ".bat" if is_windows else ".sh"
|
|
375
|
+
|
|
376
|
+
# Create temporary script file
|
|
377
|
+
with tempfile.NamedTemporaryFile(
|
|
378
|
+
mode="w",
|
|
379
|
+
suffix=script_ext,
|
|
380
|
+
delete=False,
|
|
381
|
+
) as script_file:
|
|
382
|
+
script_path = script_file.name
|
|
383
|
+
|
|
384
|
+
# On Unix/macOS, add shebang if not present
|
|
385
|
+
if not is_windows and not cmd.startswith("#!"):
|
|
386
|
+
# Use the configured shell in shebang
|
|
387
|
+
shebang = f"#!/usr/bin/env {shell}\n"
|
|
388
|
+
script_file.write(shebang)
|
|
389
|
+
|
|
390
|
+
# Add preamble if provided
|
|
391
|
+
if preamble:
|
|
392
|
+
script_file.write(preamble)
|
|
393
|
+
if not preamble.endswith("\n"):
|
|
394
|
+
script_file.write("\n")
|
|
395
|
+
|
|
396
|
+
# Write command to file
|
|
397
|
+
script_file.write(cmd)
|
|
398
|
+
script_file.flush()
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
# Make executable on Unix/macOS
|
|
402
|
+
if not is_windows:
|
|
403
|
+
os.chmod(script_path, os.stat(script_path).st_mode | stat.S_IEXEC)
|
|
404
|
+
|
|
405
|
+
# Execute script file
|
|
406
|
+
try:
|
|
407
|
+
subprocess.run(
|
|
408
|
+
[script_path],
|
|
409
|
+
cwd=working_dir,
|
|
410
|
+
check=True,
|
|
411
|
+
capture_output=False,
|
|
412
|
+
)
|
|
413
|
+
except subprocess.CalledProcessError as e:
|
|
414
|
+
raise ExecutionError(
|
|
415
|
+
f"Task '{task_name}' failed with exit code {e.returncode}"
|
|
416
|
+
)
|
|
417
|
+
finally:
|
|
418
|
+
# Clean up temporary file
|
|
419
|
+
try:
|
|
420
|
+
os.unlink(script_path)
|
|
421
|
+
except OSError:
|
|
422
|
+
pass # Ignore cleanup errors
|
|
238
423
|
|
|
239
424
|
def _substitute_args(self, cmd: str, args_dict: dict[str, Any]) -> str:
|
|
240
425
|
"""Substitute arguments in command string.
|
|
@@ -352,8 +537,9 @@ class Executor:
|
|
|
352
537
|
task: Task that was executed
|
|
353
538
|
args_dict: Arguments used for execution
|
|
354
539
|
"""
|
|
355
|
-
# Compute hashes
|
|
356
|
-
|
|
540
|
+
# Compute hashes (include effective environment)
|
|
541
|
+
effective_env = self._get_effective_env_name(task)
|
|
542
|
+
task_hash = hash_task(task.cmd, task.outputs, task.working_dir, task.args, effective_env)
|
|
357
543
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
358
544
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
359
545
|
|
tasktree/hasher.py
CHANGED
|
@@ -1,74 +1,27 @@
|
|
|
1
|
-
"""Hashing logic for tasks and arguments."""
|
|
2
|
-
|
|
3
1
|
import hashlib
|
|
4
2
|
import json
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str]) -> str:
|
|
9
|
-
"""Compute task definition hash.
|
|
3
|
+
from typing import Any, Optional
|
|
10
4
|
|
|
11
|
-
The hash includes:
|
|
12
|
-
- cmd: The command to execute
|
|
13
|
-
- outputs: Declared output files
|
|
14
|
-
- working_dir: Execution directory
|
|
15
|
-
- args: Parameter definitions (names and types)
|
|
16
5
|
|
|
17
|
-
|
|
18
|
-
- deps: Only affects scheduling order
|
|
19
|
-
- inputs: Tracked separately via timestamps
|
|
20
|
-
- desc: Documentation only
|
|
21
|
-
|
|
22
|
-
Args:
|
|
23
|
-
cmd: Command to execute
|
|
24
|
-
outputs: List of output glob patterns
|
|
25
|
-
working_dir: Working directory for execution
|
|
26
|
-
args: List of argument definitions
|
|
27
|
-
|
|
28
|
-
Returns:
|
|
29
|
-
8-character hex hash string
|
|
30
|
-
"""
|
|
31
|
-
# Create a stable representation
|
|
6
|
+
def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str], env: str = "") -> str:
|
|
32
7
|
data = {
|
|
33
8
|
"cmd": cmd,
|
|
34
|
-
"outputs": sorted(outputs),
|
|
9
|
+
"outputs": sorted(outputs),
|
|
35
10
|
"working_dir": working_dir,
|
|
36
|
-
"args": sorted(args),
|
|
11
|
+
"args": sorted(args),
|
|
12
|
+
"env": env,
|
|
37
13
|
}
|
|
38
14
|
|
|
39
|
-
# Serialize to JSON with sorted keys for deterministic hashing
|
|
40
15
|
serialized = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
41
|
-
|
|
42
|
-
# Compute hash and truncate to 8 characters
|
|
43
16
|
return hashlib.sha256(serialized.encode()).hexdigest()[:8]
|
|
44
17
|
|
|
45
18
|
|
|
46
19
|
def hash_args(args_dict: dict[str, Any]) -> str:
|
|
47
|
-
"""Compute hash of task arguments.
|
|
48
|
-
|
|
49
|
-
Args:
|
|
50
|
-
args_dict: Dictionary of argument names to values
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
8-character hex hash string
|
|
54
|
-
"""
|
|
55
|
-
# Serialize arguments to JSON with sorted keys for deterministic hashing
|
|
56
20
|
serialized = json.dumps(args_dict, sort_keys=True, separators=(",", ":"))
|
|
57
|
-
|
|
58
|
-
# Compute hash and truncate to 8 characters
|
|
59
21
|
return hashlib.sha256(serialized.encode()).hexdigest()[:8]
|
|
60
22
|
|
|
61
23
|
|
|
62
|
-
def make_cache_key(task_hash: str, args_hash: str
|
|
63
|
-
"""Create cache key for task execution.
|
|
64
|
-
|
|
65
|
-
Args:
|
|
66
|
-
task_hash: Task definition hash
|
|
67
|
-
args_hash: Optional arguments hash
|
|
68
|
-
|
|
69
|
-
Returns:
|
|
70
|
-
Cache key string (task_hash or task_hash__args_hash)
|
|
71
|
-
"""
|
|
24
|
+
def make_cache_key(task_hash: str, args_hash: Optional[str] = None) -> str:
|
|
72
25
|
if args_hash:
|
|
73
26
|
return f"{task_hash}__{args_hash}"
|
|
74
27
|
return task_hash
|
tasktree/parser.py
CHANGED
|
@@ -14,6 +14,21 @@ class CircularImportError(Exception):
|
|
|
14
14
|
pass
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
@dataclass
|
|
18
|
+
class Environment:
|
|
19
|
+
"""Represents an execution environment configuration."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
shell: str
|
|
23
|
+
args: list[str] = field(default_factory=list)
|
|
24
|
+
preamble: str = ""
|
|
25
|
+
|
|
26
|
+
def __post_init__(self):
|
|
27
|
+
"""Ensure args is always a list."""
|
|
28
|
+
if isinstance(self.args, str):
|
|
29
|
+
self.args = [self.args]
|
|
30
|
+
|
|
31
|
+
|
|
17
32
|
@dataclass
|
|
18
33
|
class Task:
|
|
19
34
|
"""Represents a task definition."""
|
|
@@ -27,6 +42,7 @@ class Task:
|
|
|
27
42
|
working_dir: str = ""
|
|
28
43
|
args: list[str] = field(default_factory=list)
|
|
29
44
|
source_file: str = "" # Track which file defined this task
|
|
45
|
+
env: str = "" # Environment name to use for execution
|
|
30
46
|
|
|
31
47
|
def __post_init__(self):
|
|
32
48
|
"""Ensure lists are always lists."""
|
|
@@ -46,6 +62,9 @@ class Recipe:
|
|
|
46
62
|
|
|
47
63
|
tasks: dict[str, Task]
|
|
48
64
|
project_root: Path
|
|
65
|
+
environments: dict[str, Environment] = field(default_factory=dict)
|
|
66
|
+
default_env: str = "" # Name of default environment
|
|
67
|
+
global_env_override: str = "" # Global environment override (set via CLI --env)
|
|
49
68
|
|
|
50
69
|
def get_task(self, name: str) -> Task | None:
|
|
51
70
|
"""Get task by name.
|
|
@@ -62,6 +81,17 @@ class Recipe:
|
|
|
62
81
|
"""Get all task names."""
|
|
63
82
|
return list(self.tasks.keys())
|
|
64
83
|
|
|
84
|
+
def get_environment(self, name: str) -> Environment | None:
|
|
85
|
+
"""Get environment by name.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
name: Environment name
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Environment if found, None otherwise
|
|
92
|
+
"""
|
|
93
|
+
return self.environments.get(name)
|
|
94
|
+
|
|
65
95
|
|
|
66
96
|
def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
67
97
|
"""Find recipe file (tasktree.yaml or tt.yaml) in current or parent directories.
|
|
@@ -94,6 +124,68 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
|
94
124
|
return None
|
|
95
125
|
|
|
96
126
|
|
|
127
|
+
def _parse_file_with_env(
|
|
128
|
+
file_path: Path,
|
|
129
|
+
namespace: str | None,
|
|
130
|
+
project_root: Path,
|
|
131
|
+
import_stack: list[Path] | None = None,
|
|
132
|
+
) -> tuple[dict[str, Task], dict[str, Environment], str]:
|
|
133
|
+
"""Parse file and extract tasks and environments.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
file_path: Path to YAML file
|
|
137
|
+
namespace: Optional namespace prefix for tasks
|
|
138
|
+
project_root: Root directory of the project
|
|
139
|
+
import_stack: Stack of files being imported (for circular detection)
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tuple of (tasks, environments, default_env_name)
|
|
143
|
+
"""
|
|
144
|
+
# Parse tasks normally
|
|
145
|
+
tasks = _parse_file(file_path, namespace, project_root, import_stack)
|
|
146
|
+
|
|
147
|
+
# Load YAML again to extract environments (only from root file)
|
|
148
|
+
environments: dict[str, Environment] = {}
|
|
149
|
+
default_env = ""
|
|
150
|
+
|
|
151
|
+
# Only parse environments from the root file (namespace is None)
|
|
152
|
+
if namespace is None:
|
|
153
|
+
with open(file_path, "r") as f:
|
|
154
|
+
data = yaml.safe_load(f)
|
|
155
|
+
|
|
156
|
+
if data and "environments" in data:
|
|
157
|
+
env_data = data["environments"]
|
|
158
|
+
if isinstance(env_data, dict):
|
|
159
|
+
# Extract default environment name
|
|
160
|
+
default_env = env_data.get("default", "")
|
|
161
|
+
|
|
162
|
+
# Parse each environment definition
|
|
163
|
+
for env_name, env_config in env_data.items():
|
|
164
|
+
if env_name == "default":
|
|
165
|
+
continue # Skip the default key itself
|
|
166
|
+
|
|
167
|
+
if not isinstance(env_config, dict):
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"Environment '{env_name}' must be a dictionary"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Parse environment configuration
|
|
173
|
+
shell = env_config.get("shell", "")
|
|
174
|
+
if not shell:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"Environment '{env_name}' must specify 'shell'"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
args = env_config.get("args", [])
|
|
180
|
+
preamble = env_config.get("preamble", "")
|
|
181
|
+
|
|
182
|
+
environments[env_name] = Environment(
|
|
183
|
+
name=env_name, shell=shell, args=args, preamble=preamble
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return tasks, environments, default_env
|
|
187
|
+
|
|
188
|
+
|
|
97
189
|
def parse_recipe(recipe_path: Path) -> Recipe:
|
|
98
190
|
"""Parse a recipe file and handle imports recursively.
|
|
99
191
|
|
|
@@ -115,9 +207,16 @@ def parse_recipe(recipe_path: Path) -> Recipe:
|
|
|
115
207
|
project_root = recipe_path.parent
|
|
116
208
|
|
|
117
209
|
# Parse main file - it will recursively handle all imports
|
|
118
|
-
tasks
|
|
210
|
+
tasks, environments, default_env = _parse_file_with_env(
|
|
211
|
+
recipe_path, namespace=None, project_root=project_root
|
|
212
|
+
)
|
|
119
213
|
|
|
120
|
-
return Recipe(
|
|
214
|
+
return Recipe(
|
|
215
|
+
tasks=tasks,
|
|
216
|
+
project_root=project_root,
|
|
217
|
+
environments=environments,
|
|
218
|
+
default_env=default_env,
|
|
219
|
+
)
|
|
121
220
|
|
|
122
221
|
|
|
123
222
|
def _parse_file(
|
|
@@ -198,10 +297,14 @@ def _parse_file(
|
|
|
198
297
|
|
|
199
298
|
tasks.update(nested_tasks)
|
|
200
299
|
|
|
300
|
+
# Determine where tasks are defined
|
|
301
|
+
# Tasks can be either at root level OR inside a "tasks:" key
|
|
302
|
+
tasks_data = data.get("tasks", data) if "tasks" in data else data
|
|
303
|
+
|
|
201
304
|
# Process local tasks
|
|
202
|
-
for task_name, task_data in
|
|
203
|
-
# Skip
|
|
204
|
-
if task_name
|
|
305
|
+
for task_name, task_data in tasks_data.items():
|
|
306
|
+
# Skip special sections (only relevant if tasks are at root level)
|
|
307
|
+
if task_name in ("import", "environments", "tasks"):
|
|
205
308
|
continue
|
|
206
309
|
|
|
207
310
|
if not isinstance(task_data, dict):
|
tasktree/types.py
CHANGED
|
@@ -4,7 +4,7 @@ import re
|
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from ipaddress import IPv4Address, IPv6Address, ip_address
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any, Optional
|
|
8
8
|
|
|
9
9
|
import click
|
|
10
10
|
|
|
@@ -19,7 +19,7 @@ class HostnameType(click.ParamType):
|
|
|
19
19
|
r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z0-9-]{1,63})*\.?$"
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
-
def convert(self, value: Any, param: click.Parameter
|
|
22
|
+
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
23
23
|
if isinstance(value, str):
|
|
24
24
|
if self.HOSTNAME_PATTERN.match(value):
|
|
25
25
|
return value
|
|
@@ -36,7 +36,7 @@ class EmailType(click.ParamType):
|
|
|
36
36
|
r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
-
def convert(self, value: Any, param: click.Parameter
|
|
39
|
+
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
40
40
|
if isinstance(value, str):
|
|
41
41
|
if self.EMAIL_PATTERN.match(value):
|
|
42
42
|
return value
|
|
@@ -48,7 +48,7 @@ class IPType(click.ParamType):
|
|
|
48
48
|
|
|
49
49
|
name = "ip"
|
|
50
50
|
|
|
51
|
-
def convert(self, value: Any, param: click.Parameter
|
|
51
|
+
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
52
52
|
try:
|
|
53
53
|
ip_address(value)
|
|
54
54
|
return str(value)
|
|
@@ -61,7 +61,7 @@ class IPv4Type(click.ParamType):
|
|
|
61
61
|
|
|
62
62
|
name = "ipv4"
|
|
63
63
|
|
|
64
|
-
def convert(self, value: Any, param: click.Parameter
|
|
64
|
+
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
65
65
|
try:
|
|
66
66
|
IPv4Address(value)
|
|
67
67
|
return str(value)
|
|
@@ -74,7 +74,7 @@ class IPv6Type(click.ParamType):
|
|
|
74
74
|
|
|
75
75
|
name = "ipv6"
|
|
76
76
|
|
|
77
|
-
def convert(self, value: Any, param: click.Parameter
|
|
77
|
+
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
78
78
|
try:
|
|
79
79
|
IPv6Address(value)
|
|
80
80
|
return str(value)
|
|
@@ -87,7 +87,7 @@ class DateTimeType(click.ParamType):
|
|
|
87
87
|
|
|
88
88
|
name = "datetime"
|
|
89
89
|
|
|
90
|
-
def convert(self, value: Any, param: click.Parameter
|
|
90
|
+
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
91
91
|
if isinstance(value, str):
|
|
92
92
|
try:
|
|
93
93
|
datetime.fromisoformat(value)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasktree
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: A task automation tool with incremental execution
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: click>=8.1.0
|
|
@@ -18,6 +18,95 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
|
|
19
19
|
A task automation tool that combines simple command execution with dependency tracking and incremental execution.
|
|
20
20
|
|
|
21
|
+
## Motivation
|
|
22
|
+
In any project of even moderate size, various scripts inevitably come into being along the way. These scripts often must be run in a particular order, or at a particular time. For historical reasons, this almost certainly a problem if your project is developed in a Linux environment; in Windows, an IDE like Visual Studio may be taking care of a significant proportion of your build, packaging and deployment tasks. Then again, it may not...
|
|
23
|
+
|
|
24
|
+
The various incantations that have to be issued to build, package, test and deploy a project can build up and then all of a sudden there's only a few people that remember which to invoke and when and then people start making helpful readme guides on what to do with the scripts and then those become out of date and start telling lies about things and so on.
|
|
25
|
+
|
|
26
|
+
Then there's the scripts themselves. In Linux, they're probably a big pile of Bash and Python, or something (Ruby, Perl, you name it). You can bet the house on people solving the problem of passing parameters to their scripts in a whole bunch of different and inconsistent ways.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
#!/usr/bin/env bash
|
|
30
|
+
# It's an environment variable defined.... somewhere?
|
|
31
|
+
echo "FOO is: $FOO"
|
|
32
|
+
```
|
|
33
|
+
```bash
|
|
34
|
+
#!/usr/bin/env bash
|
|
35
|
+
# Using simple positional arguments... guess what means what when you're invoking it!
|
|
36
|
+
echo "First: $1, Second: $2"
|
|
37
|
+
```
|
|
38
|
+
```bash
|
|
39
|
+
#!/usr/bin/env bash
|
|
40
|
+
# Oooooh fancy "make me look like a proper app" named option parsing... don't try and do --foo=bar though!
|
|
41
|
+
FOO=""
|
|
42
|
+
while [[ $# -gt 0 ]]; do
|
|
43
|
+
case "$1" in
|
|
44
|
+
--foo) FOO=$2; shift ;;
|
|
45
|
+
--) break ;;
|
|
46
|
+
*) echo "Unknown: $1";;
|
|
47
|
+
esac
|
|
48
|
+
shift
|
|
49
|
+
done
|
|
50
|
+
```
|
|
51
|
+
```bash
|
|
52
|
+
#!/usr/bin/env bash
|
|
53
|
+
# This thing...
|
|
54
|
+
ARGS=$(getopt -o f:b --long foo:,bar: -n 'myscript' -- "$@")
|
|
55
|
+
eval set -- "$ARGS"
|
|
56
|
+
while true; do
|
|
57
|
+
case "$1" in
|
|
58
|
+
-b|--bar) echo "Bar: $2"; shift 2 ;;
|
|
59
|
+
-f|--foo) echo "Foo: $2"; shift 2 ;;
|
|
60
|
+
--) shift; break ;;
|
|
61
|
+
*) break ;;
|
|
62
|
+
esac
|
|
63
|
+
done
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
What about help info? Who has time to wire that in?
|
|
67
|
+
|
|
68
|
+
### The point
|
|
69
|
+
Is this just whining and moaning? Should we just man up and revel in our own ability to memorize all the right incantations like some kind of scripting shaman?
|
|
70
|
+
|
|
71
|
+
... No. That's **a dumb idea**.
|
|
72
|
+
|
|
73
|
+
Task Tree allows you to pile all the knowledge of **what** to run, **when** to run it, **where** to run it and **how** to run it into a single, readable place. Then you can delete all the scripts that no-one knows how to use and all the readme docs that lie to the few people that actually waste their time reading them.
|
|
74
|
+
|
|
75
|
+
The tasks you need to perform to deliver your project become summarised in an executable file that looks like:
|
|
76
|
+
```yaml
|
|
77
|
+
build:
|
|
78
|
+
desc: Compile stuff
|
|
79
|
+
outputs: [target/release/bin]
|
|
80
|
+
cmd: cargo build --release
|
|
81
|
+
|
|
82
|
+
package:
|
|
83
|
+
desc: build installers
|
|
84
|
+
deps: [build]
|
|
85
|
+
outputs: [awesome.deb]
|
|
86
|
+
cmd: |
|
|
87
|
+
for bin in target/release/*; do
|
|
88
|
+
if [[ -x "$bin" && ! -d "$bin" ]]; then
|
|
89
|
+
install -Dm755 "$bin" "debian/awesome/usr/bin/$(basename "$bin")"
|
|
90
|
+
fi
|
|
91
|
+
done
|
|
92
|
+
|
|
93
|
+
dpkg-buildpackage -us -uc
|
|
94
|
+
|
|
95
|
+
test:
|
|
96
|
+
desc: Run tests
|
|
97
|
+
deps: [package]
|
|
98
|
+
inputs: [tests/**/*.py]
|
|
99
|
+
cmd: PYTHONPATH=src python3 -m pytest tests/ -v
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
If you want to run the tests then:
|
|
103
|
+
```bash
|
|
104
|
+
tt test
|
|
105
|
+
```
|
|
106
|
+
Boom! Done. `build` will always run, because there's no sensible way to know what Cargo did. However, if Cargo decided that nothing needed to be done and didn't touch the binaries, then `package` will realize that and not do anything. Then `test` will just run with the new tests that you just wrote. If you then immediately run `test` again, then `test` will figure out that none of the dependencies did anything and that none of the test files have changed and then just _do nothing_ - as it should.
|
|
107
|
+
|
|
108
|
+
This is a toy example, but you can image how it plays out on a more complex project.
|
|
109
|
+
|
|
21
110
|
## Installation
|
|
22
111
|
|
|
23
112
|
### From PyPI (Recommended)
|
|
@@ -26,6 +115,16 @@ A task automation tool that combines simple command execution with dependency tr
|
|
|
26
115
|
pipx install tasktree
|
|
27
116
|
```
|
|
28
117
|
|
|
118
|
+
If you have multiple Python interpreter versions installed, and the _default_ interpreter is a version <3.11, then you can use `pipx`'s `--python` option to specify an interpreter with a version >=3.11:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# If the target version is on the PATH
|
|
122
|
+
pipx install --python python3.12 tasktree
|
|
123
|
+
|
|
124
|
+
# With a path to an interpreter
|
|
125
|
+
pipx install --python /path/to/python3.12 tasktree
|
|
126
|
+
```
|
|
127
|
+
|
|
29
128
|
### From Source
|
|
30
129
|
|
|
31
130
|
For the latest unreleased version from GitHub:
|
|
@@ -61,9 +160,11 @@ test:
|
|
|
61
160
|
Run tasks:
|
|
62
161
|
|
|
63
162
|
```bash
|
|
64
|
-
tt
|
|
65
|
-
tt
|
|
163
|
+
tt # Print the help
|
|
164
|
+
tt --help # ...also print the help
|
|
66
165
|
tt --list # Show all available tasks
|
|
166
|
+
tt build # Build the application (assuming this is in your tasktree.yaml)
|
|
167
|
+
tt test # Run tests (builds first if needed)
|
|
67
168
|
```
|
|
68
169
|
|
|
69
170
|
## Core Concepts
|
|
@@ -72,11 +173,12 @@ tt --list # Show all available tasks
|
|
|
72
173
|
|
|
73
174
|
Task Tree only runs tasks when necessary. A task executes if:
|
|
74
175
|
|
|
75
|
-
- Its definition (command, outputs, working directory) has changed
|
|
176
|
+
- Its definition (command, outputs, working directory, environment) has changed
|
|
76
177
|
- Any input files have changed since the last run
|
|
77
178
|
- Any dependencies have re-run
|
|
78
179
|
- It has never been executed before
|
|
79
180
|
- It has no inputs or outputs (always runs)
|
|
181
|
+
- The execution environment has changed (CLI override or environment config change)
|
|
80
182
|
|
|
81
183
|
### Automatic Input Inheritance
|
|
82
184
|
|
|
@@ -105,17 +207,25 @@ All state lives in `.tasktree-state` at your project root. Stale entries are aut
|
|
|
105
207
|
```yaml
|
|
106
208
|
task-name:
|
|
107
209
|
desc: Human-readable description (optional)
|
|
108
|
-
deps: [other-task]
|
|
109
|
-
inputs: [src/**/*.go]
|
|
110
|
-
outputs: [dist/binary]
|
|
111
|
-
working_dir: subproject/
|
|
112
|
-
|
|
113
|
-
|
|
210
|
+
deps: [other-task] # Task dependencies
|
|
211
|
+
inputs: [src/**/*.go] # Explicit input files (glob patterns)
|
|
212
|
+
outputs: [dist/binary] # Output files (glob patterns)
|
|
213
|
+
working_dir: subproject/ # Execution directory (default: project root)
|
|
214
|
+
env: bash-strict # Execution environment (optional)
|
|
215
|
+
args: [param1, param2:path=default] # Task parameters
|
|
216
|
+
cmd: go build -o dist/binary # Command to execute
|
|
114
217
|
```
|
|
115
218
|
|
|
116
219
|
### Commands
|
|
117
220
|
|
|
118
|
-
|
|
221
|
+
**Single-line commands** are executed directly via the configured shell:
|
|
222
|
+
|
|
223
|
+
```yaml
|
|
224
|
+
build:
|
|
225
|
+
cmd: cargo build --release
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Multi-line commands** are written to temporary script files for proper execution:
|
|
119
229
|
|
|
120
230
|
```yaml
|
|
121
231
|
deploy:
|
|
@@ -125,7 +235,9 @@ deploy:
|
|
|
125
235
|
rsync -av dist/ server:/opt/app/
|
|
126
236
|
```
|
|
127
237
|
|
|
128
|
-
|
|
238
|
+
Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
|
|
239
|
+
|
|
240
|
+
Or use folded blocks for long single-line commands:
|
|
129
241
|
|
|
130
242
|
```yaml
|
|
131
243
|
compile:
|
|
@@ -136,6 +248,58 @@ compile:
|
|
|
136
248
|
-L lib -lm
|
|
137
249
|
```
|
|
138
250
|
|
|
251
|
+
### Execution Environments
|
|
252
|
+
|
|
253
|
+
Configure custom shell environments for task execution:
|
|
254
|
+
|
|
255
|
+
```yaml
|
|
256
|
+
environments:
|
|
257
|
+
default: bash-strict
|
|
258
|
+
|
|
259
|
+
bash-strict:
|
|
260
|
+
shell: bash
|
|
261
|
+
args: ['-c'] # For single-line: bash -c "command"
|
|
262
|
+
preamble: | # For multi-line: prepended to script
|
|
263
|
+
set -euo pipefail
|
|
264
|
+
|
|
265
|
+
python:
|
|
266
|
+
shell: python
|
|
267
|
+
args: ['-c']
|
|
268
|
+
|
|
269
|
+
powershell:
|
|
270
|
+
shell: powershell
|
|
271
|
+
args: ['-ExecutionPolicy', 'Bypass', '-Command']
|
|
272
|
+
preamble: |
|
|
273
|
+
$ErrorActionPreference = 'Stop'
|
|
274
|
+
|
|
275
|
+
tasks:
|
|
276
|
+
build:
|
|
277
|
+
# Uses 'default' environment (bash-strict)
|
|
278
|
+
cmd: cargo build --release
|
|
279
|
+
|
|
280
|
+
analyze:
|
|
281
|
+
env: python
|
|
282
|
+
cmd: |
|
|
283
|
+
import sys
|
|
284
|
+
print(f"Analyzing with Python {sys.version}")
|
|
285
|
+
# ... analysis code ...
|
|
286
|
+
|
|
287
|
+
windows-task:
|
|
288
|
+
env: powershell
|
|
289
|
+
cmd: |
|
|
290
|
+
Compress-Archive -Path dist/* -DestinationPath package.zip
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Environment resolution priority:**
|
|
294
|
+
1. CLI override: `tt --env python build`
|
|
295
|
+
2. Task's `env` field
|
|
296
|
+
3. Recipe's `default` environment
|
|
297
|
+
4. Platform default (bash on Unix, cmd on Windows)
|
|
298
|
+
|
|
299
|
+
**Platform defaults** when no environments are configured:
|
|
300
|
+
- **Unix/macOS**: bash with `-c` args
|
|
301
|
+
- **Windows**: cmd with `/c` args
|
|
302
|
+
|
|
139
303
|
### Parameterised Tasks
|
|
140
304
|
|
|
141
305
|
Tasks can accept arguments with optional defaults:
|
|
@@ -202,12 +366,19 @@ Input and output patterns support standard glob syntax:
|
|
|
202
366
|
|
|
203
367
|
### How State Works
|
|
204
368
|
|
|
205
|
-
Each task is identified by a hash of its definition
|
|
369
|
+
Each task is identified by a hash of its definition. The hash includes:
|
|
370
|
+
|
|
371
|
+
- Command to execute
|
|
372
|
+
- Output patterns
|
|
373
|
+
- Working directory
|
|
374
|
+
- Argument definitions
|
|
375
|
+
- Execution environment
|
|
206
376
|
|
|
377
|
+
State tracks:
|
|
207
378
|
- When the task last ran
|
|
208
379
|
- Timestamps of input files at that time
|
|
209
380
|
|
|
210
|
-
Tasks are re-run when their definition changes
|
|
381
|
+
Tasks are re-run when their definition changes, inputs are newer than the last run, or the environment changes.
|
|
211
382
|
|
|
212
383
|
### What's Not In The Hash
|
|
213
384
|
|
|
@@ -222,6 +393,72 @@ Changes to these don't invalidate cached state:
|
|
|
222
393
|
|
|
223
394
|
At the start of each invocation, state is checked for invalid task hashes and non-existent ones are automatically removed. Delete a task from your recipe file and its state disappears the next time you run `tt <cmd>`
|
|
224
395
|
|
|
396
|
+
## Command-Line Options
|
|
397
|
+
|
|
398
|
+
Task Tree provides several command-line options for controlling task execution:
|
|
399
|
+
|
|
400
|
+
### Execution Control
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
# Force re-run (ignore freshness checks)
|
|
404
|
+
tt --force build
|
|
405
|
+
tt -f build
|
|
406
|
+
|
|
407
|
+
# Run only the specified task, skip dependencies (implies --force)
|
|
408
|
+
tt --only deploy
|
|
409
|
+
tt -o deploy
|
|
410
|
+
|
|
411
|
+
# Override environment for all tasks
|
|
412
|
+
tt --env python analyze
|
|
413
|
+
tt -e powershell build
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Information Commands
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
# List all available tasks
|
|
420
|
+
tt --list
|
|
421
|
+
tt -l
|
|
422
|
+
|
|
423
|
+
# Show detailed task definition
|
|
424
|
+
tt --show build
|
|
425
|
+
|
|
426
|
+
# Show dependency tree (without execution)
|
|
427
|
+
tt --tree deploy
|
|
428
|
+
|
|
429
|
+
# Show version
|
|
430
|
+
tt --version
|
|
431
|
+
tt -v
|
|
432
|
+
|
|
433
|
+
# Create a blank recipe file
|
|
434
|
+
tt --init
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### State Management
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
# Remove state file (reset task cache)
|
|
441
|
+
tt --clean
|
|
442
|
+
tt --clean-state
|
|
443
|
+
tt --reset
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Common Workflows
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
# Fresh build of everything
|
|
450
|
+
tt --force build
|
|
451
|
+
|
|
452
|
+
# Run a task without rebuilding dependencies
|
|
453
|
+
tt --only test
|
|
454
|
+
|
|
455
|
+
# Test with a different shell/environment
|
|
456
|
+
tt --env python test
|
|
457
|
+
|
|
458
|
+
# Force rebuild and deploy
|
|
459
|
+
tt --force deploy production
|
|
460
|
+
```
|
|
461
|
+
|
|
225
462
|
## Example: Full Build Pipeline
|
|
226
463
|
|
|
227
464
|
```yaml
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
+
tasktree/cli.py,sha256=2Pm0pxWdG4RaQbVMijaMUIrm3v0b9J98CUsUp7cDFvI,13236
|
|
3
|
+
tasktree/executor.py,sha256=_E37tShHuiOj0Mvx2GbS9y3GIozC3hpzAVhAjbvYJqg,18638
|
|
4
|
+
tasktree/graph.py,sha256=9ngfg93y7EkOIN_lUQa0u-JhnwiMN1UdQQvIFw8RYCE,4181
|
|
5
|
+
tasktree/hasher.py,sha256=puJey9wF_p37k_xqjhYr_6ICsbAfrTBWHec6MqKV4BU,814
|
|
6
|
+
tasktree/parser.py,sha256=apLfN3_YVa7lQIy0rcHFwo931Pg-8mKG1044AQE6fLQ,12690
|
|
7
|
+
tasktree/state.py,sha256=rxKtS3SbsPtAuraHbN807RGWfoYYkQ3pe8CxUstwo2k,3535
|
|
8
|
+
tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
|
|
9
|
+
tasktree/types.py,sha256=w--sKjRTc8mGYkU5eAduqV86SolDqOYspAPuVKIuSQQ,3797
|
|
10
|
+
tasktree-0.0.4.dist-info/METADATA,sha256=dxJ5QiaRWhBbae-cK358ESQuwbjDg04ITWomlgrO9Lc,16312
|
|
11
|
+
tasktree-0.0.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
tasktree-0.0.4.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
13
|
+
tasktree-0.0.4.dist-info/RECORD,,
|
tasktree-0.0.2.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
-
tasktree/cli.py,sha256=VZGB8FJ8glZLn_z2bjSLLbmGRfFZ5Zh4nc1cYDLjGck,15904
|
|
3
|
-
tasktree/executor.py,sha256=ZM-qEsgvjfRz77GEZzxF29L3I2d8Uz5W1ev7Mox9Vnc,12287
|
|
4
|
-
tasktree/graph.py,sha256=9ngfg93y7EkOIN_lUQa0u-JhnwiMN1UdQQvIFw8RYCE,4181
|
|
5
|
-
tasktree/hasher.py,sha256=jvXBvIfFH9g6AOBHHd012v8nG5JOgVnHH35mIEtVkwc,2133
|
|
6
|
-
tasktree/parser.py,sha256=F2LbB84NinvBQT8zLONxn036R9Rued2y4XB3WGZ8PLk,9150
|
|
7
|
-
tasktree/state.py,sha256=rxKtS3SbsPtAuraHbN807RGWfoYYkQ3pe8CxUstwo2k,3535
|
|
8
|
-
tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
|
|
9
|
-
tasktree/types.py,sha256=wrBzO-Z2ebCTRjWyOWNvuCjqAq-74Zyb9E4FQ4beF38,3751
|
|
10
|
-
tasktree-0.0.2.dist-info/METADATA,sha256=yUoIhImEiwFND5rcCwzVdHosfW6BMvwbvdQgqF8EjnE,9145
|
|
11
|
-
tasktree-0.0.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
-
tasktree-0.0.2.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
13
|
-
tasktree-0.0.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|