tasktree 0.0.4__py3-none-any.whl → 0.0.6__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 +85 -49
- tasktree/parser.py +85 -14
- {tasktree-0.0.4.dist-info → tasktree-0.0.6.dist-info}/METADATA +198 -111
- {tasktree-0.0.4.dist-info → tasktree-0.0.6.dist-info}/RECORD +6 -6
- {tasktree-0.0.4.dist-info → tasktree-0.0.6.dist-info}/WHEEL +0 -0
- {tasktree-0.0.4.dist-info → tasktree-0.0.6.dist-info}/entry_points.txt +0 -0
tasktree/cli.py
CHANGED
|
@@ -26,11 +26,11 @@ app = typer.Typer(
|
|
|
26
26
|
console = Console()
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def _list_tasks():
|
|
29
|
+
def _list_tasks(tasks_file: Optional[str] = None):
|
|
30
30
|
"""List all available tasks with descriptions."""
|
|
31
|
-
recipe = _get_recipe()
|
|
31
|
+
recipe = _get_recipe(tasks_file)
|
|
32
32
|
if recipe is None:
|
|
33
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
33
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
34
34
|
raise typer.Exit(1)
|
|
35
35
|
|
|
36
36
|
table = Table(title="Available Tasks")
|
|
@@ -45,11 +45,11 @@ def _list_tasks():
|
|
|
45
45
|
console.print(table)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def _show_task(task_name: str):
|
|
48
|
+
def _show_task(task_name: str, tasks_file: Optional[str] = None):
|
|
49
49
|
"""Show task definition with syntax highlighting."""
|
|
50
|
-
recipe = _get_recipe()
|
|
50
|
+
recipe = _get_recipe(tasks_file)
|
|
51
51
|
if recipe is None:
|
|
52
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
52
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
53
53
|
raise typer.Exit(1)
|
|
54
54
|
|
|
55
55
|
task = recipe.get_task(task_name)
|
|
@@ -79,17 +79,26 @@ def _show_task(task_name: str):
|
|
|
79
79
|
task_dict = task_yaml[task_name]
|
|
80
80
|
task_yaml[task_name] = {k: v for k, v in task_dict.items() if v}
|
|
81
81
|
|
|
82
|
+
# Configure YAML dumper to use literal block style for multiline strings
|
|
83
|
+
def literal_presenter(dumper, data):
|
|
84
|
+
"""Use literal block style (|) for strings containing newlines."""
|
|
85
|
+
if '\n' in data:
|
|
86
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
|
|
87
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', data)
|
|
88
|
+
|
|
89
|
+
yaml.add_representer(str, literal_presenter)
|
|
90
|
+
|
|
82
91
|
# Format and highlight using Rich
|
|
83
92
|
yaml_str = yaml.dump(task_yaml, default_flow_style=False, sort_keys=False)
|
|
84
93
|
syntax = Syntax(yaml_str, "yaml", theme="ansi_light", line_numbers=False)
|
|
85
94
|
console.print(syntax)
|
|
86
95
|
|
|
87
96
|
|
|
88
|
-
def _show_tree(task_name: str):
|
|
97
|
+
def _show_tree(task_name: str, tasks_file: Optional[str] = None):
|
|
89
98
|
"""Show dependency tree structure."""
|
|
90
|
-
recipe = _get_recipe()
|
|
99
|
+
recipe = _get_recipe(tasks_file)
|
|
91
100
|
if recipe is None:
|
|
92
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
101
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
93
102
|
raise typer.Exit(1)
|
|
94
103
|
|
|
95
104
|
task = recipe.get_task(task_name)
|
|
@@ -117,27 +126,28 @@ def _init_recipe():
|
|
|
117
126
|
raise typer.Exit(1)
|
|
118
127
|
|
|
119
128
|
template = """# Task Tree Recipe
|
|
120
|
-
# See https://github.com/kevinchannon/
|
|
129
|
+
# See https://github.com/kevinchannon/tasktree for documentation
|
|
121
130
|
|
|
122
131
|
# Example task definitions:
|
|
123
132
|
|
|
124
|
-
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
133
|
+
tasks:
|
|
134
|
+
# build:
|
|
135
|
+
# desc: Compile the application
|
|
136
|
+
# outputs: [target/release/bin]
|
|
137
|
+
# cmd: cargo build --release
|
|
138
|
+
|
|
139
|
+
# test:
|
|
140
|
+
# desc: Run tests
|
|
141
|
+
# deps: [build]
|
|
142
|
+
# cmd: cargo test
|
|
143
|
+
|
|
144
|
+
# deploy:
|
|
145
|
+
# desc: Deploy to environment
|
|
146
|
+
# deps: [build]
|
|
147
|
+
# args: [environment, region=eu-west-1]
|
|
148
|
+
# cmd: |
|
|
149
|
+
# echo "Deploying to {{environment}} in {{region}}"
|
|
150
|
+
# ./deploy.sh {{environment}} {{region}}
|
|
141
151
|
|
|
142
152
|
# Uncomment and modify the examples above to define your tasks
|
|
143
153
|
"""
|
|
@@ -168,6 +178,7 @@ def main(
|
|
|
168
178
|
list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
|
|
169
179
|
show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
|
|
170
180
|
tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
|
|
181
|
+
tasks_file: Optional[str] = typer.Option(None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"),
|
|
171
182
|
init: Optional[bool] = typer.Option(
|
|
172
183
|
None, "--init", "-i", help="Create a blank tasktree.yaml"
|
|
173
184
|
),
|
|
@@ -207,15 +218,15 @@ def main(
|
|
|
207
218
|
"""
|
|
208
219
|
|
|
209
220
|
if list_opt:
|
|
210
|
-
_list_tasks()
|
|
221
|
+
_list_tasks(tasks_file)
|
|
211
222
|
raise typer.Exit()
|
|
212
223
|
|
|
213
224
|
if show:
|
|
214
|
-
_show_task(show)
|
|
225
|
+
_show_task(show, tasks_file)
|
|
215
226
|
raise typer.Exit()
|
|
216
227
|
|
|
217
228
|
if tree:
|
|
218
|
-
_show_tree(tree)
|
|
229
|
+
_show_tree(tree, tasks_file)
|
|
219
230
|
raise typer.Exit()
|
|
220
231
|
|
|
221
232
|
if init:
|
|
@@ -223,17 +234,17 @@ def main(
|
|
|
223
234
|
raise typer.Exit()
|
|
224
235
|
|
|
225
236
|
if clean or clean_state or reset:
|
|
226
|
-
_clean_state()
|
|
237
|
+
_clean_state(tasks_file)
|
|
227
238
|
raise typer.Exit()
|
|
228
239
|
|
|
229
240
|
if task_args:
|
|
230
241
|
# --only implies --force
|
|
231
242
|
force_execution = force or only or False
|
|
232
|
-
_execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env)
|
|
243
|
+
_execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env, tasks_file=tasks_file)
|
|
233
244
|
else:
|
|
234
|
-
recipe = _get_recipe()
|
|
245
|
+
recipe = _get_recipe(tasks_file)
|
|
235
246
|
if recipe is None:
|
|
236
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
247
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
237
248
|
console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
|
|
238
249
|
raise typer.Exit(1)
|
|
239
250
|
|
|
@@ -244,13 +255,19 @@ def main(
|
|
|
244
255
|
console.print("Use [cyan]tt <task-name>[/cyan] to run a task")
|
|
245
256
|
|
|
246
257
|
|
|
247
|
-
def _clean_state() -> None:
|
|
258
|
+
def _clean_state(tasks_file: Optional[str] = None) -> None:
|
|
248
259
|
"""Remove the .tasktree-state file to reset task execution state."""
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
260
|
+
if tasks_file:
|
|
261
|
+
recipe_path = Path(tasks_file)
|
|
262
|
+
if not recipe_path.exists():
|
|
263
|
+
console.print(f"[red]Recipe file not found: {tasks_file}[/red]")
|
|
264
|
+
raise typer.Exit(1)
|
|
265
|
+
else:
|
|
266
|
+
recipe_path = find_recipe_file()
|
|
267
|
+
if recipe_path is None:
|
|
268
|
+
console.print("[yellow]No recipe file found[/yellow]")
|
|
269
|
+
console.print("State file location depends on recipe file location")
|
|
270
|
+
raise typer.Exit(1)
|
|
254
271
|
|
|
255
272
|
project_root = recipe_path.parent
|
|
256
273
|
state_path = project_root / ".tasktree-state"
|
|
@@ -263,29 +280,48 @@ def _clean_state() -> None:
|
|
|
263
280
|
console.print(f"[yellow]No state file found at {state_path}[/yellow]")
|
|
264
281
|
|
|
265
282
|
|
|
266
|
-
def _get_recipe() -> Optional[Recipe]:
|
|
267
|
-
"""Get parsed recipe or None if not found.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
283
|
+
def _get_recipe(recipe_file: Optional[str] = None) -> Optional[Recipe]:
|
|
284
|
+
"""Get parsed recipe or None if not found.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
|
|
288
|
+
"""
|
|
289
|
+
if recipe_file:
|
|
290
|
+
recipe_path = Path(recipe_file)
|
|
291
|
+
if not recipe_path.exists():
|
|
292
|
+
console.print(f"[red]Recipe file not found: {recipe_file}[/red]")
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
# When explicitly specified, project root is current working directory
|
|
295
|
+
project_root = Path.cwd()
|
|
296
|
+
else:
|
|
297
|
+
try:
|
|
298
|
+
recipe_path = find_recipe_file()
|
|
299
|
+
if recipe_path is None:
|
|
300
|
+
return None
|
|
301
|
+
except ValueError as e:
|
|
302
|
+
# Multiple recipe files found
|
|
303
|
+
console.print(f"[red]{e}[/red]")
|
|
304
|
+
raise typer.Exit(1)
|
|
305
|
+
# When auto-discovered, project root is recipe file's parent
|
|
306
|
+
project_root = None
|
|
271
307
|
|
|
272
308
|
try:
|
|
273
|
-
return parse_recipe(recipe_path)
|
|
309
|
+
return parse_recipe(recipe_path, project_root)
|
|
274
310
|
except Exception as e:
|
|
275
311
|
console.print(f"[red]Error parsing recipe: {e}[/red]")
|
|
276
312
|
raise typer.Exit(1)
|
|
277
313
|
|
|
278
314
|
|
|
279
|
-
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None) -> None:
|
|
315
|
+
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None, tasks_file: Optional[str] = None) -> None:
|
|
280
316
|
if not args:
|
|
281
317
|
return
|
|
282
318
|
|
|
283
319
|
task_name = args[0]
|
|
284
320
|
task_args = args[1:]
|
|
285
321
|
|
|
286
|
-
recipe = _get_recipe()
|
|
322
|
+
recipe = _get_recipe(tasks_file)
|
|
287
323
|
if recipe is None:
|
|
288
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
324
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
289
325
|
raise typer.Exit(1)
|
|
290
326
|
|
|
291
327
|
# Apply global environment override if provided
|
tasktree/parser.py
CHANGED
|
@@ -94,13 +94,25 @@ class Recipe:
|
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
97
|
-
"""Find recipe file
|
|
97
|
+
"""Find recipe file in current or parent directories.
|
|
98
|
+
|
|
99
|
+
Looks for recipe files matching these patterns (in order of preference):
|
|
100
|
+
- tasktree.yaml
|
|
101
|
+
- tasktree.yml
|
|
102
|
+
- tt.yaml
|
|
103
|
+
- *.tasks
|
|
104
|
+
|
|
105
|
+
If multiple recipe files are found in the same directory, raises ValueError
|
|
106
|
+
with instructions to use --tasks option.
|
|
98
107
|
|
|
99
108
|
Args:
|
|
100
109
|
start_dir: Directory to start searching from (defaults to cwd)
|
|
101
110
|
|
|
102
111
|
Returns:
|
|
103
112
|
Path to recipe file if found, None otherwise
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: If multiple recipe files found in the same directory
|
|
104
116
|
"""
|
|
105
117
|
if start_dir is None:
|
|
106
118
|
start_dir = Path.cwd()
|
|
@@ -109,10 +121,30 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
|
109
121
|
|
|
110
122
|
# Search up the directory tree
|
|
111
123
|
while True:
|
|
112
|
-
|
|
124
|
+
candidates = []
|
|
125
|
+
|
|
126
|
+
# Check for exact filenames first
|
|
127
|
+
for filename in ["tasktree.yaml", "tasktree.yml", "tt.yaml"]:
|
|
113
128
|
recipe_path = current / filename
|
|
114
129
|
if recipe_path.exists():
|
|
115
|
-
|
|
130
|
+
candidates.append(recipe_path)
|
|
131
|
+
|
|
132
|
+
# Check for *.tasks files
|
|
133
|
+
for tasks_file in current.glob("*.tasks"):
|
|
134
|
+
if tasks_file.is_file():
|
|
135
|
+
candidates.append(tasks_file)
|
|
136
|
+
|
|
137
|
+
if len(candidates) > 1:
|
|
138
|
+
# Multiple recipe files found - ambiguous
|
|
139
|
+
filenames = [c.name for c in candidates]
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Multiple recipe files found in {current}:\n"
|
|
142
|
+
f" {', '.join(filenames)}\n\n"
|
|
143
|
+
f"Please specify which file to use with --tasks (-T):\n"
|
|
144
|
+
f" tt --tasks {filenames[0]} <task-name>"
|
|
145
|
+
)
|
|
146
|
+
elif len(candidates) == 1:
|
|
147
|
+
return candidates[0]
|
|
116
148
|
|
|
117
149
|
# Move to parent directory
|
|
118
150
|
parent = current.parent
|
|
@@ -186,11 +218,13 @@ def _parse_file_with_env(
|
|
|
186
218
|
return tasks, environments, default_env
|
|
187
219
|
|
|
188
220
|
|
|
189
|
-
def parse_recipe(recipe_path: Path) -> Recipe:
|
|
221
|
+
def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
|
|
190
222
|
"""Parse a recipe file and handle imports recursively.
|
|
191
223
|
|
|
192
224
|
Args:
|
|
193
225
|
recipe_path: Path to the main recipe file
|
|
226
|
+
project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
|
|
227
|
+
When using --tasks option, this should be the current working directory.
|
|
194
228
|
|
|
195
229
|
Returns:
|
|
196
230
|
Recipe object with all tasks (including recursively imported tasks)
|
|
@@ -204,7 +238,9 @@ def parse_recipe(recipe_path: Path) -> Recipe:
|
|
|
204
238
|
if not recipe_path.exists():
|
|
205
239
|
raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
|
|
206
240
|
|
|
207
|
-
|
|
241
|
+
# Default project root to recipe file's parent if not specified
|
|
242
|
+
if project_root is None:
|
|
243
|
+
project_root = recipe_path.parent
|
|
208
244
|
|
|
209
245
|
# Parse main file - it will recursively handle all imports
|
|
210
246
|
tasks, environments, default_env = _parse_file_with_env(
|
|
@@ -263,14 +299,15 @@ def _parse_file(
|
|
|
263
299
|
tasks: dict[str, Task] = {}
|
|
264
300
|
file_dir = file_path.parent
|
|
265
301
|
|
|
266
|
-
# Default working directory is the
|
|
267
|
-
|
|
302
|
+
# Default working directory is the project root (where tt is invoked)
|
|
303
|
+
# NOT the directory where the tasks file is located
|
|
304
|
+
default_working_dir = "."
|
|
268
305
|
|
|
269
306
|
# Track local import namespaces for dependency rewriting
|
|
270
307
|
local_import_namespaces: set[str] = set()
|
|
271
308
|
|
|
272
309
|
# Process nested imports FIRST
|
|
273
|
-
imports = data.get("
|
|
310
|
+
imports = data.get("imports", [])
|
|
274
311
|
if imports:
|
|
275
312
|
for import_spec in imports:
|
|
276
313
|
child_file = import_spec["file"]
|
|
@@ -297,15 +334,49 @@ def _parse_file(
|
|
|
297
334
|
|
|
298
335
|
tasks.update(nested_tasks)
|
|
299
336
|
|
|
300
|
-
#
|
|
301
|
-
|
|
302
|
-
|
|
337
|
+
# Validate top-level keys (only imports, environments, and tasks are allowed)
|
|
338
|
+
VALID_TOP_LEVEL_KEYS = {"imports", "environments", "tasks"}
|
|
339
|
+
|
|
340
|
+
# Check if tasks key is missing when there appear to be task definitions at root
|
|
341
|
+
# Do this BEFORE checking for unknown keys, to provide better error message
|
|
342
|
+
if "tasks" not in data and data:
|
|
343
|
+
# Check if there are potential task definitions at root level
|
|
344
|
+
potential_tasks = [
|
|
345
|
+
k for k, v in data.items()
|
|
346
|
+
if isinstance(v, dict) and k not in VALID_TOP_LEVEL_KEYS
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
if potential_tasks:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"Invalid recipe format in {file_path}\n\n"
|
|
352
|
+
f"Task definitions must be under a top-level 'tasks:' key.\n\n"
|
|
353
|
+
f"Found these keys at root level: {', '.join(potential_tasks)}\n\n"
|
|
354
|
+
f"Did you mean:\n\n"
|
|
355
|
+
f"tasks:\n"
|
|
356
|
+
+ '\n'.join(f" {k}:" for k in potential_tasks) +
|
|
357
|
+
"\n cmd: ...\n\n"
|
|
358
|
+
f"Valid top-level keys are: {', '.join(sorted(VALID_TOP_LEVEL_KEYS))}"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Now check for other invalid top-level keys (non-dict values)
|
|
362
|
+
invalid_keys = set(data.keys()) - VALID_TOP_LEVEL_KEYS
|
|
363
|
+
if invalid_keys:
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"Invalid recipe format in {file_path}\n\n"
|
|
366
|
+
f"Unknown top-level keys: {', '.join(sorted(invalid_keys))}\n\n"
|
|
367
|
+
f"Valid top-level keys are:\n"
|
|
368
|
+
f" - imports (for importing task files)\n"
|
|
369
|
+
f" - environments (for shell environment configuration)\n"
|
|
370
|
+
f" - tasks (for task definitions)"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Extract tasks from "tasks" key
|
|
374
|
+
tasks_data = data.get("tasks", {})
|
|
375
|
+
if tasks_data is None:
|
|
376
|
+
tasks_data = {}
|
|
303
377
|
|
|
304
378
|
# Process local tasks
|
|
305
379
|
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"):
|
|
308
|
-
continue
|
|
309
380
|
|
|
310
381
|
if not isinstance(task_data, dict):
|
|
311
382
|
raise ValueError(f"Task '{task_name}' must be a dictionary")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasktree
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.6
|
|
4
4
|
Summary: A task automation tool with incremental execution
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: click>=8.1.0
|
|
@@ -70,33 +70,34 @@ Is this just whining and moaning? Should we just man up and revel in our own abi
|
|
|
70
70
|
|
|
71
71
|
... No. That's **a dumb idea**.
|
|
72
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.
|
|
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
74
|
|
|
75
75
|
The tasks you need to perform to deliver your project become summarised in an executable file that looks like:
|
|
76
76
|
```yaml
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
77
|
+
tasks:
|
|
78
|
+
build:
|
|
79
|
+
desc: Compile stuff
|
|
80
|
+
outputs: [target/release/bin]
|
|
81
|
+
cmd: cargo build --release
|
|
82
|
+
|
|
83
|
+
package:
|
|
84
|
+
desc: build installers
|
|
85
|
+
deps: [build]
|
|
86
|
+
outputs: [awesome.deb]
|
|
87
|
+
cmd: |
|
|
88
|
+
for bin in target/release/*; do
|
|
89
|
+
if [[ -x "$bin" && ! -d "$bin" ]]; then
|
|
90
|
+
install -Dm755 "$bin" "debian/awesome/usr/bin/$(basename "$bin")"
|
|
91
|
+
fi
|
|
92
|
+
done
|
|
93
|
+
|
|
94
|
+
dpkg-buildpackage -us -uc
|
|
95
|
+
|
|
96
|
+
test:
|
|
97
|
+
desc: Run tests
|
|
98
|
+
deps: [package]
|
|
99
|
+
inputs: [tests/**/*.py]
|
|
100
|
+
cmd: PYTHONPATH=src python3 -m pytest tests/ -v
|
|
100
101
|
```
|
|
101
102
|
|
|
102
103
|
If you want to run the tests then:
|
|
@@ -107,6 +108,52 @@ Boom! Done. `build` will always run, because there's no sensible way to know wha
|
|
|
107
108
|
|
|
108
109
|
This is a toy example, but you can image how it plays out on a more complex project.
|
|
109
110
|
|
|
111
|
+
## Migrating from v1.x to v2.0
|
|
112
|
+
|
|
113
|
+
Version 2.0 requires all task definitions to be under a top-level `tasks:` key.
|
|
114
|
+
|
|
115
|
+
### Quick Migration
|
|
116
|
+
|
|
117
|
+
Wrap your existing tasks in a `tasks:` block:
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
# Before (v1.x)
|
|
121
|
+
build:
|
|
122
|
+
cmd: cargo build
|
|
123
|
+
|
|
124
|
+
# After (v2.0)
|
|
125
|
+
tasks:
|
|
126
|
+
build:
|
|
127
|
+
cmd: cargo build
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Why This Change?
|
|
131
|
+
|
|
132
|
+
1. **Clearer structure**: Explicit separation of tasks from configuration
|
|
133
|
+
2. **No naming conflicts**: You can now create tasks named "imports" or "environments"
|
|
134
|
+
3. **Better error messages**: More helpful validation errors
|
|
135
|
+
4. **Consistency**: All recipe files use the same format
|
|
136
|
+
|
|
137
|
+
### Error Messages
|
|
138
|
+
|
|
139
|
+
If you forget to update, you'll see a clear error:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
Invalid recipe format in tasktree.yaml
|
|
143
|
+
|
|
144
|
+
Task definitions must be under a top-level "tasks:" key.
|
|
145
|
+
|
|
146
|
+
Found these keys at root level: build, test
|
|
147
|
+
|
|
148
|
+
Did you mean:
|
|
149
|
+
|
|
150
|
+
tasks:
|
|
151
|
+
build:
|
|
152
|
+
cmd: ...
|
|
153
|
+
test:
|
|
154
|
+
cmd: ...
|
|
155
|
+
```
|
|
156
|
+
|
|
110
157
|
## Installation
|
|
111
158
|
|
|
112
159
|
### From PyPI (Recommended)
|
|
@@ -141,20 +188,52 @@ cd tasktree
|
|
|
141
188
|
pipx install .
|
|
142
189
|
```
|
|
143
190
|
|
|
191
|
+
## Editor Support
|
|
192
|
+
|
|
193
|
+
Task Tree includes a [JSON Schema](schema/tasktree-schema.json) that provides autocomplete, validation, and documentation in modern editors.
|
|
194
|
+
|
|
195
|
+
### VS Code
|
|
196
|
+
|
|
197
|
+
Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then add to your workspace `.vscode/settings.json`:
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{
|
|
201
|
+
"yaml.schemas": {
|
|
202
|
+
"https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
|
|
203
|
+
"tasktree.yaml",
|
|
204
|
+
"tt.yaml"
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Or add a comment at the top of your `tasktree.yaml`:
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
|
|
214
|
+
|
|
215
|
+
tasks:
|
|
216
|
+
build:
|
|
217
|
+
cmd: cargo build
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
See [schema/README.md](schema/README.md) for IntelliJ/PyCharm and command-line validation.
|
|
221
|
+
|
|
144
222
|
## Quick Start
|
|
145
223
|
|
|
146
224
|
Create a `tasktree.yaml` (or `tt.yaml`) in your project:
|
|
147
225
|
|
|
148
226
|
```yaml
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
227
|
+
tasks:
|
|
228
|
+
build:
|
|
229
|
+
desc: Compile the application
|
|
230
|
+
outputs: [target/release/bin]
|
|
231
|
+
cmd: cargo build --release
|
|
153
232
|
|
|
154
|
-
test:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
233
|
+
test:
|
|
234
|
+
desc: Run tests
|
|
235
|
+
deps: [build]
|
|
236
|
+
cmd: cargo test
|
|
158
237
|
```
|
|
159
238
|
|
|
160
239
|
Run tasks:
|
|
@@ -185,15 +264,16 @@ Task Tree only runs tasks when necessary. A task executes if:
|
|
|
185
264
|
Tasks automatically inherit inputs from dependencies, eliminating redundant declarations:
|
|
186
265
|
|
|
187
266
|
```yaml
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
267
|
+
tasks:
|
|
268
|
+
build:
|
|
269
|
+
outputs: [dist/app]
|
|
270
|
+
cmd: go build -o dist/app
|
|
271
|
+
|
|
272
|
+
package:
|
|
273
|
+
deps: [build]
|
|
274
|
+
outputs: [dist/app.tar.gz]
|
|
275
|
+
cmd: tar czf dist/app.tar.gz dist/app
|
|
276
|
+
# Automatically tracks dist/app as an input
|
|
197
277
|
```
|
|
198
278
|
|
|
199
279
|
### Single State File
|
|
@@ -205,15 +285,16 @@ All state lives in `.tasktree-state` at your project root. Stale entries are aut
|
|
|
205
285
|
### Basic Structure
|
|
206
286
|
|
|
207
287
|
```yaml
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
288
|
+
tasks:
|
|
289
|
+
task-name:
|
|
290
|
+
desc: Human-readable description (optional)
|
|
291
|
+
deps: [other-task] # Task dependencies
|
|
292
|
+
inputs: [src/**/*.go] # Explicit input files (glob patterns)
|
|
293
|
+
outputs: [dist/binary] # Output files (glob patterns)
|
|
294
|
+
working_dir: subproject/ # Execution directory (default: project root)
|
|
295
|
+
env: bash-strict # Execution environment (optional)
|
|
296
|
+
args: [param1, param2:path=default] # Task parameters
|
|
297
|
+
cmd: go build -o dist/binary # Command to execute
|
|
217
298
|
```
|
|
218
299
|
|
|
219
300
|
### Commands
|
|
@@ -221,18 +302,20 @@ task-name:
|
|
|
221
302
|
**Single-line commands** are executed directly via the configured shell:
|
|
222
303
|
|
|
223
304
|
```yaml
|
|
224
|
-
|
|
225
|
-
|
|
305
|
+
tasks:
|
|
306
|
+
build:
|
|
307
|
+
cmd: cargo build --release
|
|
226
308
|
```
|
|
227
309
|
|
|
228
310
|
**Multi-line commands** are written to temporary script files for proper execution:
|
|
229
311
|
|
|
230
312
|
```yaml
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
313
|
+
tasks:
|
|
314
|
+
deploy:
|
|
315
|
+
cmd: |
|
|
316
|
+
mkdir -p dist
|
|
317
|
+
cp build/* dist/
|
|
318
|
+
rsync -av dist/ server:/opt/app/
|
|
236
319
|
```
|
|
237
320
|
|
|
238
321
|
Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
|
|
@@ -240,12 +323,13 @@ Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) a
|
|
|
240
323
|
Or use folded blocks for long single-line commands:
|
|
241
324
|
|
|
242
325
|
```yaml
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
326
|
+
tasks:
|
|
327
|
+
compile:
|
|
328
|
+
cmd: >
|
|
329
|
+
gcc -o bin/app
|
|
330
|
+
src/*.c
|
|
331
|
+
-I include
|
|
332
|
+
-L lib -lm
|
|
249
333
|
```
|
|
250
334
|
|
|
251
335
|
### Execution Environments
|
|
@@ -305,12 +389,13 @@ tasks:
|
|
|
305
389
|
Tasks can accept arguments with optional defaults:
|
|
306
390
|
|
|
307
391
|
```yaml
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
392
|
+
tasks:
|
|
393
|
+
deploy:
|
|
394
|
+
args: [environment, region=eu-west-1]
|
|
395
|
+
deps: [build]
|
|
396
|
+
cmd: |
|
|
397
|
+
aws s3 cp dist/app.zip s3://{{environment}}-{{region}}/
|
|
398
|
+
aws lambda update-function-code --function-name app-{{environment}}
|
|
314
399
|
```
|
|
315
400
|
|
|
316
401
|
Invoke with: `tt deploy production` or `tt deploy staging us-east-1` or `tt deploy staging region=us-east-1`.
|
|
@@ -337,18 +422,19 @@ Split task definitions across multiple files for better organisation:
|
|
|
337
422
|
|
|
338
423
|
```yaml
|
|
339
424
|
# tasktree.yaml
|
|
340
|
-
|
|
425
|
+
imports:
|
|
341
426
|
- file: build/tasks.yml
|
|
342
427
|
as: build
|
|
343
428
|
- file: deploy/tasks.yml
|
|
344
429
|
as: deploy
|
|
345
430
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
431
|
+
tasks:
|
|
432
|
+
test:
|
|
433
|
+
deps: [build.compile, build.test-compile]
|
|
434
|
+
cmd: ./run-tests.sh
|
|
349
435
|
|
|
350
|
-
ci:
|
|
351
|
-
|
|
436
|
+
ci:
|
|
437
|
+
deps: [build.all, test, deploy.staging]
|
|
352
438
|
```
|
|
353
439
|
|
|
354
440
|
Imported tasks are namespaced and can be referenced as dependencies. Each imported file is self-contained—it cannot depend on tasks in the importing file.
|
|
@@ -466,41 +552,42 @@ imports:
|
|
|
466
552
|
- file: common/docker.yml
|
|
467
553
|
as: docker
|
|
468
554
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
555
|
+
tasks:
|
|
556
|
+
compile:
|
|
557
|
+
desc: Build application binaries
|
|
558
|
+
outputs: [target/release/app]
|
|
559
|
+
cmd: cargo build --release
|
|
560
|
+
|
|
561
|
+
test-unit:
|
|
562
|
+
desc: Run unit tests
|
|
563
|
+
deps: [compile]
|
|
564
|
+
cmd: cargo test
|
|
565
|
+
|
|
566
|
+
package:
|
|
567
|
+
desc: Create distribution archive
|
|
568
|
+
deps: [compile]
|
|
569
|
+
outputs: [dist/app-{{version}}.tar.gz]
|
|
570
|
+
args: [version]
|
|
571
|
+
cmd: |
|
|
572
|
+
mkdir -p dist
|
|
573
|
+
tar czf dist/app-{{version}}.tar.gz \
|
|
574
|
+
target/release/app \
|
|
575
|
+
config/ \
|
|
576
|
+
migrations/
|
|
577
|
+
|
|
578
|
+
deploy:
|
|
579
|
+
desc: Deploy to environment
|
|
580
|
+
deps: [package, docker.build-runtime]
|
|
581
|
+
args: [environment, version]
|
|
582
|
+
cmd: |
|
|
583
|
+
scp dist/app-{{version}}.tar.gz {{environment}}:/opt/
|
|
584
|
+
ssh {{environment}} /opt/deploy.sh {{version}}
|
|
585
|
+
|
|
586
|
+
integration-test:
|
|
587
|
+
desc: Run integration tests against deployed environment
|
|
588
|
+
deps: [deploy]
|
|
589
|
+
args: [environment, version]
|
|
590
|
+
cmd: pytest tests/integration/ --env={{environment}}
|
|
504
591
|
```
|
|
505
592
|
|
|
506
593
|
Run the full pipeline:
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
-
tasktree/cli.py,sha256=
|
|
2
|
+
tasktree/cli.py,sha256=0xusNitT1AtLgR3guUsupnHSXJ0_C749Dx7dfYCENJA,15233
|
|
3
3
|
tasktree/executor.py,sha256=_E37tShHuiOj0Mvx2GbS9y3GIozC3hpzAVhAjbvYJqg,18638
|
|
4
4
|
tasktree/graph.py,sha256=9ngfg93y7EkOIN_lUQa0u-JhnwiMN1UdQQvIFw8RYCE,4181
|
|
5
5
|
tasktree/hasher.py,sha256=puJey9wF_p37k_xqjhYr_6ICsbAfrTBWHec6MqKV4BU,814
|
|
6
|
-
tasktree/parser.py,sha256=
|
|
6
|
+
tasktree/parser.py,sha256=SzWn-V4KMgjxNZrN0ERApb5dd39LPJTfkA2Ih2nYWcs,15580
|
|
7
7
|
tasktree/state.py,sha256=rxKtS3SbsPtAuraHbN807RGWfoYYkQ3pe8CxUstwo2k,3535
|
|
8
8
|
tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
|
|
9
9
|
tasktree/types.py,sha256=w--sKjRTc8mGYkU5eAduqV86SolDqOYspAPuVKIuSQQ,3797
|
|
10
|
-
tasktree-0.0.
|
|
11
|
-
tasktree-0.0.
|
|
12
|
-
tasktree-0.0.
|
|
13
|
-
tasktree-0.0.
|
|
10
|
+
tasktree-0.0.6.dist-info/METADATA,sha256=GQZHcFVOIXfWmgPbb9pAt7zB0iH22hyGToc0YEwUv34,18287
|
|
11
|
+
tasktree-0.0.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
tasktree-0.0.6.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
13
|
+
tasktree-0.0.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|