tasktree 0.0.3__py3-none-any.whl → 0.0.5__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 +30 -70
- tasktree/hasher.py +5 -55
- tasktree/parser.py +41 -7
- tasktree/types.py +7 -7
- {tasktree-0.0.3.dist-info → tasktree-0.0.5.dist-info}/METADATA +246 -89
- tasktree-0.0.5.dist-info/RECORD +13 -0
- tasktree-0.0.3.dist-info/RECORD +0 -13
- {tasktree-0.0.3.dist-info → tasktree-0.0.5.dist-info}/WHEEL +0 -0
- {tasktree-0.0.3.dist-info → tasktree-0.0.5.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
|
|
|
@@ -124,23 +121,24 @@ def _init_recipe():
|
|
|
124
121
|
|
|
125
122
|
# Example task definitions:
|
|
126
123
|
|
|
127
|
-
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
124
|
+
tasks:
|
|
125
|
+
# build:
|
|
126
|
+
# desc: Compile the application
|
|
127
|
+
# outputs: [target/release/bin]
|
|
128
|
+
# cmd: cargo build --release
|
|
129
|
+
|
|
130
|
+
# test:
|
|
131
|
+
# desc: Run tests
|
|
132
|
+
# deps: [build]
|
|
133
|
+
# cmd: cargo test
|
|
134
|
+
|
|
135
|
+
# deploy:
|
|
136
|
+
# desc: Deploy to environment
|
|
137
|
+
# deps: [build]
|
|
138
|
+
# args: [environment, region=eu-west-1]
|
|
139
|
+
# cmd: |
|
|
140
|
+
# echo "Deploying to {{environment}} in {{region}}"
|
|
141
|
+
# ./deploy.sh {{environment}} {{region}}
|
|
144
142
|
|
|
145
143
|
# Uncomment and modify the examples above to define your tasks
|
|
146
144
|
"""
|
|
@@ -168,22 +166,20 @@ def main(
|
|
|
168
166
|
is_eager=True,
|
|
169
167
|
help="Show version and exit",
|
|
170
168
|
),
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
),
|
|
174
|
-
show: Optional[str] = typer.Option(None, "--show", help="Show task definition"),
|
|
175
|
-
tree: Optional[str] = typer.Option(None, "--tree", help="Show dependency tree"),
|
|
169
|
+
list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
|
|
170
|
+
show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
|
|
171
|
+
tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
|
|
176
172
|
init: Optional[bool] = typer.Option(
|
|
177
|
-
None, "--init", help="Create a blank tasktree.yaml"
|
|
173
|
+
None, "--init", "-i", help="Create a blank tasktree.yaml"
|
|
178
174
|
),
|
|
179
175
|
clean: Optional[bool] = typer.Option(
|
|
180
|
-
None, "--clean", help="Remove state file (reset task cache)"
|
|
176
|
+
None, "--clean", "-c", help="Remove state file (reset task cache)"
|
|
181
177
|
),
|
|
182
178
|
clean_state: Optional[bool] = typer.Option(
|
|
183
|
-
None, "--clean-state", help="Remove state file (reset task cache)"
|
|
179
|
+
None, "--clean-state", "-C", help="Remove state file (reset task cache)"
|
|
184
180
|
),
|
|
185
181
|
reset: Optional[bool] = typer.Option(
|
|
186
|
-
None, "--reset", help="Remove state file (reset task cache)"
|
|
182
|
+
None, "--reset", "-r", help="Remove state file (reset task cache)"
|
|
187
183
|
),
|
|
188
184
|
force: Optional[bool] = typer.Option(
|
|
189
185
|
None, "--force", "-f", help="Force re-run all tasks (ignore freshness)"
|
|
@@ -210,38 +206,32 @@ def main(
|
|
|
210
206
|
tt --list # List all tasks
|
|
211
207
|
tt --tree test # Show dependency tree for 'test'
|
|
212
208
|
"""
|
|
213
|
-
|
|
214
|
-
if
|
|
209
|
+
|
|
210
|
+
if list_opt:
|
|
215
211
|
_list_tasks()
|
|
216
212
|
raise typer.Exit()
|
|
217
213
|
|
|
218
|
-
# Handle show option
|
|
219
214
|
if show:
|
|
220
215
|
_show_task(show)
|
|
221
216
|
raise typer.Exit()
|
|
222
217
|
|
|
223
|
-
# Handle tree option
|
|
224
218
|
if tree:
|
|
225
219
|
_show_tree(tree)
|
|
226
220
|
raise typer.Exit()
|
|
227
221
|
|
|
228
|
-
# Handle init option
|
|
229
222
|
if init:
|
|
230
223
|
_init_recipe()
|
|
231
224
|
raise typer.Exit()
|
|
232
225
|
|
|
233
|
-
# Handle clean options (all three aliases)
|
|
234
226
|
if clean or clean_state or reset:
|
|
235
227
|
_clean_state()
|
|
236
228
|
raise typer.Exit()
|
|
237
229
|
|
|
238
|
-
# Handle task execution
|
|
239
230
|
if task_args:
|
|
240
|
-
#
|
|
231
|
+
# --only implies --force
|
|
241
232
|
force_execution = force or only or False
|
|
242
233
|
_execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env)
|
|
243
234
|
else:
|
|
244
|
-
# No arguments - show available tasks
|
|
245
235
|
recipe = _get_recipe()
|
|
246
236
|
if recipe is None:
|
|
247
237
|
console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
|
|
@@ -274,7 +264,7 @@ def _clean_state() -> None:
|
|
|
274
264
|
console.print(f"[yellow]No state file found at {state_path}[/yellow]")
|
|
275
265
|
|
|
276
266
|
|
|
277
|
-
def _get_recipe() -> Recipe
|
|
267
|
+
def _get_recipe() -> Optional[Recipe]:
|
|
278
268
|
"""Get parsed recipe or None if not found."""
|
|
279
269
|
recipe_path = find_recipe_file()
|
|
280
270
|
if recipe_path is None:
|
|
@@ -287,15 +277,7 @@ def _get_recipe() -> Recipe | None:
|
|
|
287
277
|
raise typer.Exit(1)
|
|
288
278
|
|
|
289
279
|
|
|
290
|
-
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: str
|
|
291
|
-
"""Execute a task specified by name with arguments.
|
|
292
|
-
|
|
293
|
-
Args:
|
|
294
|
-
args: Command line arguments (task name and task arguments)
|
|
295
|
-
force: If True, ignore freshness and re-run all tasks
|
|
296
|
-
only: If True, run only the specified task without dependencies
|
|
297
|
-
env: If provided, override environment for all tasks
|
|
298
|
-
"""
|
|
280
|
+
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None) -> None:
|
|
299
281
|
if not args:
|
|
300
282
|
return
|
|
301
283
|
|
|
@@ -350,31 +332,17 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
|
|
|
350
332
|
|
|
351
333
|
|
|
352
334
|
def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, Any]:
|
|
353
|
-
"""Parse command line arguments for a task.
|
|
354
|
-
|
|
355
|
-
Args:
|
|
356
|
-
arg_specs: Argument specifications from task definition
|
|
357
|
-
arg_values: Command line argument values
|
|
358
|
-
|
|
359
|
-
Returns:
|
|
360
|
-
Dictionary of argument names to values
|
|
361
|
-
|
|
362
|
-
Raises:
|
|
363
|
-
typer.Exit: If arguments are invalid
|
|
364
|
-
"""
|
|
365
335
|
if not arg_specs:
|
|
366
336
|
if arg_values:
|
|
367
337
|
console.print(f"[red]Task does not accept arguments[/red]")
|
|
368
338
|
raise typer.Exit(1)
|
|
369
339
|
return {}
|
|
370
340
|
|
|
371
|
-
# Parse argument specifications
|
|
372
341
|
parsed_specs = []
|
|
373
342
|
for spec in arg_specs:
|
|
374
343
|
name, arg_type, default = parse_arg_spec(spec)
|
|
375
344
|
parsed_specs.append((name, arg_type, default))
|
|
376
345
|
|
|
377
|
-
# Build argument dictionary
|
|
378
346
|
args_dict = {}
|
|
379
347
|
positional_index = 0
|
|
380
348
|
|
|
@@ -424,14 +392,6 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
424
392
|
|
|
425
393
|
|
|
426
394
|
def _build_rich_tree(dep_tree: dict) -> Tree:
|
|
427
|
-
"""Build a Rich Tree from dependency tree structure.
|
|
428
|
-
|
|
429
|
-
Args:
|
|
430
|
-
dep_tree: Dependency tree structure
|
|
431
|
-
|
|
432
|
-
Returns:
|
|
433
|
-
Rich Tree for display
|
|
434
|
-
"""
|
|
435
395
|
task_name = dep_tree["name"]
|
|
436
396
|
tree = Tree(task_name)
|
|
437
397
|
|
tasktree/hasher.py
CHANGED
|
@@ -1,77 +1,27 @@
|
|
|
1
|
-
"""Hashing logic for tasks and arguments."""
|
|
2
|
-
|
|
3
1
|
import hashlib
|
|
4
2
|
import json
|
|
5
|
-
from typing import Any
|
|
3
|
+
from typing import Any, Optional
|
|
6
4
|
|
|
7
5
|
|
|
8
6
|
def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str], env: str = "") -> str:
|
|
9
|
-
"""Compute task definition hash.
|
|
10
|
-
|
|
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
|
-
- env: Environment name (effective environment after resolution)
|
|
17
|
-
|
|
18
|
-
The hash excludes:
|
|
19
|
-
- deps: Only affects scheduling order
|
|
20
|
-
- inputs: Tracked separately via timestamps
|
|
21
|
-
- desc: Documentation only
|
|
22
|
-
|
|
23
|
-
Args:
|
|
24
|
-
cmd: Command to execute
|
|
25
|
-
outputs: List of output glob patterns
|
|
26
|
-
working_dir: Working directory for execution
|
|
27
|
-
args: List of argument definitions
|
|
28
|
-
env: Environment name (empty string for platform default)
|
|
29
|
-
|
|
30
|
-
Returns:
|
|
31
|
-
8-character hex hash string
|
|
32
|
-
"""
|
|
33
|
-
# Create a stable representation
|
|
34
7
|
data = {
|
|
35
8
|
"cmd": cmd,
|
|
36
|
-
"outputs": sorted(outputs),
|
|
9
|
+
"outputs": sorted(outputs),
|
|
37
10
|
"working_dir": working_dir,
|
|
38
|
-
"args": sorted(args),
|
|
39
|
-
"env": env,
|
|
11
|
+
"args": sorted(args),
|
|
12
|
+
"env": env,
|
|
40
13
|
}
|
|
41
14
|
|
|
42
|
-
# Serialize to JSON with sorted keys for deterministic hashing
|
|
43
15
|
serialized = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
44
|
-
|
|
45
|
-
# Compute hash and truncate to 8 characters
|
|
46
16
|
return hashlib.sha256(serialized.encode()).hexdigest()[:8]
|
|
47
17
|
|
|
48
18
|
|
|
49
19
|
def hash_args(args_dict: dict[str, Any]) -> str:
|
|
50
|
-
"""Compute hash of task arguments.
|
|
51
|
-
|
|
52
|
-
Args:
|
|
53
|
-
args_dict: Dictionary of argument names to values
|
|
54
|
-
|
|
55
|
-
Returns:
|
|
56
|
-
8-character hex hash string
|
|
57
|
-
"""
|
|
58
|
-
# Serialize arguments to JSON with sorted keys for deterministic hashing
|
|
59
20
|
serialized = json.dumps(args_dict, sort_keys=True, separators=(",", ":"))
|
|
60
|
-
|
|
61
|
-
# Compute hash and truncate to 8 characters
|
|
62
21
|
return hashlib.sha256(serialized.encode()).hexdigest()[:8]
|
|
63
22
|
|
|
64
23
|
|
|
65
|
-
def make_cache_key(task_hash: str, args_hash: str
|
|
66
|
-
"""Create cache key for task execution.
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
task_hash: Task definition hash
|
|
70
|
-
args_hash: Optional arguments hash
|
|
71
|
-
|
|
72
|
-
Returns:
|
|
73
|
-
Cache key string (task_hash or task_hash__args_hash)
|
|
74
|
-
"""
|
|
24
|
+
def make_cache_key(task_hash: str, args_hash: Optional[str] = None) -> str:
|
|
75
25
|
if args_hash:
|
|
76
26
|
return f"{task_hash}__{args_hash}"
|
|
77
27
|
return task_hash
|
tasktree/parser.py
CHANGED
|
@@ -270,7 +270,7 @@ def _parse_file(
|
|
|
270
270
|
local_import_namespaces: set[str] = set()
|
|
271
271
|
|
|
272
272
|
# Process nested imports FIRST
|
|
273
|
-
imports = data.get("
|
|
273
|
+
imports = data.get("imports", [])
|
|
274
274
|
if imports:
|
|
275
275
|
for import_spec in imports:
|
|
276
276
|
child_file = import_spec["file"]
|
|
@@ -297,15 +297,49 @@ def _parse_file(
|
|
|
297
297
|
|
|
298
298
|
tasks.update(nested_tasks)
|
|
299
299
|
|
|
300
|
-
#
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
# Validate top-level keys (only imports, environments, and tasks are allowed)
|
|
301
|
+
VALID_TOP_LEVEL_KEYS = {"imports", "environments", "tasks"}
|
|
302
|
+
|
|
303
|
+
# Check if tasks key is missing when there appear to be task definitions at root
|
|
304
|
+
# Do this BEFORE checking for unknown keys, to provide better error message
|
|
305
|
+
if "tasks" not in data and data:
|
|
306
|
+
# Check if there are potential task definitions at root level
|
|
307
|
+
potential_tasks = [
|
|
308
|
+
k for k, v in data.items()
|
|
309
|
+
if isinstance(v, dict) and k not in VALID_TOP_LEVEL_KEYS
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
if potential_tasks:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
f"Invalid recipe format in {file_path}\n\n"
|
|
315
|
+
f"Task definitions must be under a top-level 'tasks:' key.\n\n"
|
|
316
|
+
f"Found these keys at root level: {', '.join(potential_tasks)}\n\n"
|
|
317
|
+
f"Did you mean:\n\n"
|
|
318
|
+
f"tasks:\n"
|
|
319
|
+
+ '\n'.join(f" {k}:" for k in potential_tasks) +
|
|
320
|
+
"\n cmd: ...\n\n"
|
|
321
|
+
f"Valid top-level keys are: {', '.join(sorted(VALID_TOP_LEVEL_KEYS))}"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Now check for other invalid top-level keys (non-dict values)
|
|
325
|
+
invalid_keys = set(data.keys()) - VALID_TOP_LEVEL_KEYS
|
|
326
|
+
if invalid_keys:
|
|
327
|
+
raise ValueError(
|
|
328
|
+
f"Invalid recipe format in {file_path}\n\n"
|
|
329
|
+
f"Unknown top-level keys: {', '.join(sorted(invalid_keys))}\n\n"
|
|
330
|
+
f"Valid top-level keys are:\n"
|
|
331
|
+
f" - imports (for importing task files)\n"
|
|
332
|
+
f" - environments (for shell environment configuration)\n"
|
|
333
|
+
f" - tasks (for task definitions)"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Extract tasks from "tasks" key
|
|
337
|
+
tasks_data = data.get("tasks", {})
|
|
338
|
+
if tasks_data is None:
|
|
339
|
+
tasks_data = {}
|
|
303
340
|
|
|
304
341
|
# Process local tasks
|
|
305
342
|
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
343
|
|
|
310
344
|
if not isinstance(task_data, dict):
|
|
311
345
|
raise ValueError(f"Task '{task_name}' must be a dictionary")
|
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.5
|
|
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,142 @@ 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
|
+
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
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
If you want to run the tests then:
|
|
104
|
+
```bash
|
|
105
|
+
tt test
|
|
106
|
+
```
|
|
107
|
+
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.
|
|
108
|
+
|
|
109
|
+
This is a toy example, but you can image how it plays out on a more complex project.
|
|
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
|
+
|
|
21
157
|
## Installation
|
|
22
158
|
|
|
23
159
|
### From PyPI (Recommended)
|
|
@@ -26,6 +162,16 @@ A task automation tool that combines simple command execution with dependency tr
|
|
|
26
162
|
pipx install tasktree
|
|
27
163
|
```
|
|
28
164
|
|
|
165
|
+
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:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
# If the target version is on the PATH
|
|
169
|
+
pipx install --python python3.12 tasktree
|
|
170
|
+
|
|
171
|
+
# With a path to an interpreter
|
|
172
|
+
pipx install --python /path/to/python3.12 tasktree
|
|
173
|
+
```
|
|
174
|
+
|
|
29
175
|
### From Source
|
|
30
176
|
|
|
31
177
|
For the latest unreleased version from GitHub:
|
|
@@ -47,23 +193,26 @@ pipx install .
|
|
|
47
193
|
Create a `tasktree.yaml` (or `tt.yaml`) in your project:
|
|
48
194
|
|
|
49
195
|
```yaml
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
196
|
+
tasks:
|
|
197
|
+
build:
|
|
198
|
+
desc: Compile the application
|
|
199
|
+
outputs: [target/release/bin]
|
|
200
|
+
cmd: cargo build --release
|
|
54
201
|
|
|
55
|
-
test:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
202
|
+
test:
|
|
203
|
+
desc: Run tests
|
|
204
|
+
deps: [build]
|
|
205
|
+
cmd: cargo test
|
|
59
206
|
```
|
|
60
207
|
|
|
61
208
|
Run tasks:
|
|
62
209
|
|
|
63
210
|
```bash
|
|
64
|
-
tt
|
|
65
|
-
tt
|
|
211
|
+
tt # Print the help
|
|
212
|
+
tt --help # ...also print the help
|
|
66
213
|
tt --list # Show all available tasks
|
|
214
|
+
tt build # Build the application (assuming this is in your tasktree.yaml)
|
|
215
|
+
tt test # Run tests (builds first if needed)
|
|
67
216
|
```
|
|
68
217
|
|
|
69
218
|
## Core Concepts
|
|
@@ -84,15 +233,16 @@ Task Tree only runs tasks when necessary. A task executes if:
|
|
|
84
233
|
Tasks automatically inherit inputs from dependencies, eliminating redundant declarations:
|
|
85
234
|
|
|
86
235
|
```yaml
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
236
|
+
tasks:
|
|
237
|
+
build:
|
|
238
|
+
outputs: [dist/app]
|
|
239
|
+
cmd: go build -o dist/app
|
|
240
|
+
|
|
241
|
+
package:
|
|
242
|
+
deps: [build]
|
|
243
|
+
outputs: [dist/app.tar.gz]
|
|
244
|
+
cmd: tar czf dist/app.tar.gz dist/app
|
|
245
|
+
# Automatically tracks dist/app as an input
|
|
96
246
|
```
|
|
97
247
|
|
|
98
248
|
### Single State File
|
|
@@ -104,15 +254,16 @@ All state lives in `.tasktree-state` at your project root. Stale entries are aut
|
|
|
104
254
|
### Basic Structure
|
|
105
255
|
|
|
106
256
|
```yaml
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
257
|
+
tasks:
|
|
258
|
+
task-name:
|
|
259
|
+
desc: Human-readable description (optional)
|
|
260
|
+
deps: [other-task] # Task dependencies
|
|
261
|
+
inputs: [src/**/*.go] # Explicit input files (glob patterns)
|
|
262
|
+
outputs: [dist/binary] # Output files (glob patterns)
|
|
263
|
+
working_dir: subproject/ # Execution directory (default: project root)
|
|
264
|
+
env: bash-strict # Execution environment (optional)
|
|
265
|
+
args: [param1, param2:path=default] # Task parameters
|
|
266
|
+
cmd: go build -o dist/binary # Command to execute
|
|
116
267
|
```
|
|
117
268
|
|
|
118
269
|
### Commands
|
|
@@ -120,18 +271,20 @@ task-name:
|
|
|
120
271
|
**Single-line commands** are executed directly via the configured shell:
|
|
121
272
|
|
|
122
273
|
```yaml
|
|
123
|
-
|
|
124
|
-
|
|
274
|
+
tasks:
|
|
275
|
+
build:
|
|
276
|
+
cmd: cargo build --release
|
|
125
277
|
```
|
|
126
278
|
|
|
127
279
|
**Multi-line commands** are written to temporary script files for proper execution:
|
|
128
280
|
|
|
129
281
|
```yaml
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
282
|
+
tasks:
|
|
283
|
+
deploy:
|
|
284
|
+
cmd: |
|
|
285
|
+
mkdir -p dist
|
|
286
|
+
cp build/* dist/
|
|
287
|
+
rsync -av dist/ server:/opt/app/
|
|
135
288
|
```
|
|
136
289
|
|
|
137
290
|
Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
|
|
@@ -139,12 +292,13 @@ Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) a
|
|
|
139
292
|
Or use folded blocks for long single-line commands:
|
|
140
293
|
|
|
141
294
|
```yaml
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
295
|
+
tasks:
|
|
296
|
+
compile:
|
|
297
|
+
cmd: >
|
|
298
|
+
gcc -o bin/app
|
|
299
|
+
src/*.c
|
|
300
|
+
-I include
|
|
301
|
+
-L lib -lm
|
|
148
302
|
```
|
|
149
303
|
|
|
150
304
|
### Execution Environments
|
|
@@ -204,12 +358,13 @@ tasks:
|
|
|
204
358
|
Tasks can accept arguments with optional defaults:
|
|
205
359
|
|
|
206
360
|
```yaml
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
361
|
+
tasks:
|
|
362
|
+
deploy:
|
|
363
|
+
args: [environment, region=eu-west-1]
|
|
364
|
+
deps: [build]
|
|
365
|
+
cmd: |
|
|
366
|
+
aws s3 cp dist/app.zip s3://{{environment}}-{{region}}/
|
|
367
|
+
aws lambda update-function-code --function-name app-{{environment}}
|
|
213
368
|
```
|
|
214
369
|
|
|
215
370
|
Invoke with: `tt deploy production` or `tt deploy staging us-east-1` or `tt deploy staging region=us-east-1`.
|
|
@@ -236,18 +391,19 @@ Split task definitions across multiple files for better organisation:
|
|
|
236
391
|
|
|
237
392
|
```yaml
|
|
238
393
|
# tasktree.yaml
|
|
239
|
-
|
|
394
|
+
imports:
|
|
240
395
|
- file: build/tasks.yml
|
|
241
396
|
as: build
|
|
242
397
|
- file: deploy/tasks.yml
|
|
243
398
|
as: deploy
|
|
244
399
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
400
|
+
tasks:
|
|
401
|
+
test:
|
|
402
|
+
deps: [build.compile, build.test-compile]
|
|
403
|
+
cmd: ./run-tests.sh
|
|
248
404
|
|
|
249
|
-
ci:
|
|
250
|
-
|
|
405
|
+
ci:
|
|
406
|
+
deps: [build.all, test, deploy.staging]
|
|
251
407
|
```
|
|
252
408
|
|
|
253
409
|
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.
|
|
@@ -365,41 +521,42 @@ imports:
|
|
|
365
521
|
- file: common/docker.yml
|
|
366
522
|
as: docker
|
|
367
523
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
524
|
+
tasks:
|
|
525
|
+
compile:
|
|
526
|
+
desc: Build application binaries
|
|
527
|
+
outputs: [target/release/app]
|
|
528
|
+
cmd: cargo build --release
|
|
529
|
+
|
|
530
|
+
test-unit:
|
|
531
|
+
desc: Run unit tests
|
|
532
|
+
deps: [compile]
|
|
533
|
+
cmd: cargo test
|
|
534
|
+
|
|
535
|
+
package:
|
|
536
|
+
desc: Create distribution archive
|
|
537
|
+
deps: [compile]
|
|
538
|
+
outputs: [dist/app-{{version}}.tar.gz]
|
|
539
|
+
args: [version]
|
|
540
|
+
cmd: |
|
|
541
|
+
mkdir -p dist
|
|
542
|
+
tar czf dist/app-{{version}}.tar.gz \
|
|
543
|
+
target/release/app \
|
|
544
|
+
config/ \
|
|
545
|
+
migrations/
|
|
546
|
+
|
|
547
|
+
deploy:
|
|
548
|
+
desc: Deploy to environment
|
|
549
|
+
deps: [package, docker.build-runtime]
|
|
550
|
+
args: [environment, version]
|
|
551
|
+
cmd: |
|
|
552
|
+
scp dist/app-{{version}}.tar.gz {{environment}}:/opt/
|
|
553
|
+
ssh {{environment}} /opt/deploy.sh {{version}}
|
|
554
|
+
|
|
555
|
+
integration-test:
|
|
556
|
+
desc: Run integration tests against deployed environment
|
|
557
|
+
deps: [deploy]
|
|
558
|
+
args: [environment, version]
|
|
559
|
+
cmd: pytest tests/integration/ --env={{environment}}
|
|
403
560
|
```
|
|
404
561
|
|
|
405
562
|
Run the full pipeline:
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
+
tasktree/cli.py,sha256=jeLFmyFjTeF6JXDPLI46H5Xao9br68tPWL7D6LbC7u0,13272
|
|
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=5WXoqN2cUtO9icIPDSvK5889XinBcAQMBCyNoJ2ZO6w,14152
|
|
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.5.dist-info/METADATA,sha256=ncZRu7zCKZRd5YGG5fAWAL1TtH1mXbtnTqAne3RHdeA,17456
|
|
11
|
+
tasktree-0.0.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
tasktree-0.0.5.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
13
|
+
tasktree-0.0.5.dist-info/RECORD,,
|
tasktree-0.0.3.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
-
tasktree/cli.py,sha256=hfxKF4N8nKPvy3WwyQgENHNH8YbUD3Zd7DLHGuHdeHU,14400
|
|
3
|
-
tasktree/executor.py,sha256=_E37tShHuiOj0Mvx2GbS9y3GIozC3hpzAVhAjbvYJqg,18638
|
|
4
|
-
tasktree/graph.py,sha256=9ngfg93y7EkOIN_lUQa0u-JhnwiMN1UdQQvIFw8RYCE,4181
|
|
5
|
-
tasktree/hasher.py,sha256=xOeth3vufP-QrjnVgTDh02clkZb867aJPD6HSjhMNsg,2336
|
|
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=wrBzO-Z2ebCTRjWyOWNvuCjqAq-74Zyb9E4FQ4beF38,3751
|
|
10
|
-
tasktree-0.0.3.dist-info/METADATA,sha256=qlpV-egfSY502xJKLmmam1CTUVpVEiU4oS10LntL7gE,11961
|
|
11
|
-
tasktree-0.0.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
-
tasktree-0.0.3.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
13
|
-
tasktree-0.0.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|