tasktree 0.0.1__py3-none-any.whl → 0.0.3__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 +41 -91
- tasktree/executor.py +222 -23
- tasktree/hasher.py +4 -1
- tasktree/parser.py +108 -5
- {tasktree-0.0.1.dist-info → tasktree-0.0.3.dist-info}/METADATA +157 -33
- tasktree-0.0.3.dist-info/RECORD +13 -0
- tasktree-0.0.1.dist-info/RECORD +0 -13
- {tasktree-0.0.1.dist-info → tasktree-0.0.3.dist-info}/WHEEL +0 -0
- {tasktree-0.0.1.dist-info → tasktree-0.0.3.dist-info}/entry_points.txt +0 -0
tasktree/cli.py
CHANGED
|
@@ -89,7 +89,7 @@ def _show_task(task_name: str):
|
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
def _show_tree(task_name: str):
|
|
92
|
-
"""Show dependency tree
|
|
92
|
+
"""Show dependency tree structure."""
|
|
93
93
|
recipe = _get_recipe()
|
|
94
94
|
if recipe is None:
|
|
95
95
|
console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
|
|
@@ -107,59 +107,11 @@ def _show_tree(task_name: str):
|
|
|
107
107
|
console.print(f"[red]Error building dependency tree: {e}[/red]")
|
|
108
108
|
raise typer.Exit(1)
|
|
109
109
|
|
|
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
110
|
# Build Rich tree
|
|
117
|
-
tree = _build_rich_tree(dep_tree
|
|
111
|
+
tree = _build_rich_tree(dep_tree)
|
|
118
112
|
console.print(tree)
|
|
119
113
|
|
|
120
114
|
|
|
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
115
|
def _init_recipe():
|
|
164
116
|
"""Create a blank recipe file with commented examples."""
|
|
165
117
|
recipe_path = Path("tasktree.yaml")
|
|
@@ -221,9 +173,6 @@ def main(
|
|
|
221
173
|
),
|
|
222
174
|
show: Optional[str] = typer.Option(None, "--show", help="Show task definition"),
|
|
223
175
|
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
|
-
),
|
|
227
176
|
init: Optional[bool] = typer.Option(
|
|
228
177
|
None, "--init", help="Create a blank tasktree.yaml"
|
|
229
178
|
),
|
|
@@ -236,13 +185,22 @@ def main(
|
|
|
236
185
|
reset: Optional[bool] = typer.Option(
|
|
237
186
|
None, "--reset", help="Remove state file (reset task cache)"
|
|
238
187
|
),
|
|
188
|
+
force: Optional[bool] = typer.Option(
|
|
189
|
+
None, "--force", "-f", help="Force re-run all tasks (ignore freshness)"
|
|
190
|
+
),
|
|
191
|
+
only: Optional[bool] = typer.Option(
|
|
192
|
+
None, "--only", "-o", help="Run only the specified task, skip dependencies (implies --force)"
|
|
193
|
+
),
|
|
194
|
+
env: Optional[str] = typer.Option(
|
|
195
|
+
None, "--env", "-e", help="Override environment for all tasks"
|
|
196
|
+
),
|
|
239
197
|
task_args: Optional[List[str]] = typer.Argument(
|
|
240
198
|
None, help="Task name and arguments"
|
|
241
199
|
),
|
|
242
200
|
):
|
|
243
|
-
"""Task Tree - A task automation tool with
|
|
201
|
+
"""Task Tree - A task automation tool with incremental execution.
|
|
244
202
|
|
|
245
|
-
Run tasks defined in tasktree.yaml with
|
|
203
|
+
Run tasks defined in tasktree.yaml with dependency tracking
|
|
246
204
|
and incremental execution.
|
|
247
205
|
|
|
248
206
|
Examples:
|
|
@@ -267,11 +225,6 @@ def main(
|
|
|
267
225
|
_show_tree(tree)
|
|
268
226
|
raise typer.Exit()
|
|
269
227
|
|
|
270
|
-
# Handle dry-run option
|
|
271
|
-
if dry_run:
|
|
272
|
-
_dry_run(dry_run)
|
|
273
|
-
raise typer.Exit()
|
|
274
|
-
|
|
275
228
|
# Handle init option
|
|
276
229
|
if init:
|
|
277
230
|
_init_recipe()
|
|
@@ -284,7 +237,9 @@ def main(
|
|
|
284
237
|
|
|
285
238
|
# Handle task execution
|
|
286
239
|
if task_args:
|
|
287
|
-
|
|
240
|
+
# When --only is specified, force execution (--only implies --force)
|
|
241
|
+
force_execution = force or only or False
|
|
242
|
+
_execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env)
|
|
288
243
|
else:
|
|
289
244
|
# No arguments - show available tasks
|
|
290
245
|
recipe = _get_recipe()
|
|
@@ -332,11 +287,14 @@ def _get_recipe() -> Recipe | None:
|
|
|
332
287
|
raise typer.Exit(1)
|
|
333
288
|
|
|
334
289
|
|
|
335
|
-
def _execute_dynamic_task(args: list[str]) -> None:
|
|
290
|
+
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: str | None = None) -> None:
|
|
336
291
|
"""Execute a task specified by name with arguments.
|
|
337
292
|
|
|
338
293
|
Args:
|
|
339
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
|
|
340
298
|
"""
|
|
341
299
|
if not args:
|
|
342
300
|
return
|
|
@@ -349,6 +307,17 @@ def _execute_dynamic_task(args: list[str]) -> None:
|
|
|
349
307
|
console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
|
|
350
308
|
raise typer.Exit(1)
|
|
351
309
|
|
|
310
|
+
# Apply global environment override if provided
|
|
311
|
+
if env:
|
|
312
|
+
# Validate that the environment exists
|
|
313
|
+
if not recipe.get_environment(env):
|
|
314
|
+
console.print(f"[red]Environment not found: {env}[/red]")
|
|
315
|
+
console.print("\nAvailable environments:")
|
|
316
|
+
for env_name in sorted(recipe.environments.keys()):
|
|
317
|
+
console.print(f" - {env_name}")
|
|
318
|
+
raise typer.Exit(1)
|
|
319
|
+
recipe.global_env_override = env
|
|
320
|
+
|
|
352
321
|
task = recipe.get_task(task_name)
|
|
353
322
|
if task is None:
|
|
354
323
|
console.print(f"[red]Task not found: {task_name}[/red]")
|
|
@@ -360,20 +329,20 @@ def _execute_dynamic_task(args: list[str]) -> None:
|
|
|
360
329
|
# Parse task arguments
|
|
361
330
|
args_dict = _parse_task_args(task.args, task_args)
|
|
362
331
|
|
|
363
|
-
#
|
|
332
|
+
# Create executor and state manager
|
|
364
333
|
state = StateManager(recipe.project_root)
|
|
365
334
|
state.load()
|
|
335
|
+
executor = Executor(recipe, state)
|
|
336
|
+
|
|
337
|
+
# Prune state before execution (compute hashes with effective environment)
|
|
366
338
|
valid_hashes = {
|
|
367
|
-
hash_task(t.cmd, t.outputs, t.working_dir, t.args)
|
|
339
|
+
hash_task(t.cmd, t.outputs, t.working_dir, t.args, executor._get_effective_env_name(t))
|
|
368
340
|
for t in recipe.tasks.values()
|
|
369
341
|
}
|
|
370
342
|
state.prune(valid_hashes)
|
|
371
343
|
state.save()
|
|
372
|
-
|
|
373
|
-
# Execute task
|
|
374
|
-
executor = Executor(recipe, state)
|
|
375
344
|
try:
|
|
376
|
-
executor.execute_task(task_name, args_dict,
|
|
345
|
+
executor.execute_task(task_name, args_dict, force=force, only=only)
|
|
377
346
|
console.print(f"[green]✓ Task '{task_name}' completed successfully[/green]")
|
|
378
347
|
except Exception as e:
|
|
379
348
|
console.print(f"[red]✗ Task '{task_name}' failed: {e}[/red]")
|
|
@@ -454,40 +423,21 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
454
423
|
return args_dict
|
|
455
424
|
|
|
456
425
|
|
|
457
|
-
def _build_rich_tree(dep_tree: dict
|
|
458
|
-
"""Build a Rich Tree from dependency tree
|
|
426
|
+
def _build_rich_tree(dep_tree: dict) -> Tree:
|
|
427
|
+
"""Build a Rich Tree from dependency tree structure.
|
|
459
428
|
|
|
460
429
|
Args:
|
|
461
430
|
dep_tree: Dependency tree structure
|
|
462
|
-
statuses: Task execution statuses
|
|
463
431
|
|
|
464
432
|
Returns:
|
|
465
433
|
Rich Tree for display
|
|
466
434
|
"""
|
|
467
435
|
task_name = dep_tree["name"]
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
# Determine color based on status
|
|
471
|
-
if status:
|
|
472
|
-
if status.will_run:
|
|
473
|
-
if status.reason == "dependency_triggered":
|
|
474
|
-
color = "yellow"
|
|
475
|
-
label = f"{task_name} (triggered by dependency)"
|
|
476
|
-
else:
|
|
477
|
-
color = "red"
|
|
478
|
-
label = f"{task_name} (stale: {status.reason})"
|
|
479
|
-
else:
|
|
480
|
-
color = "green"
|
|
481
|
-
label = f"{task_name} (fresh)"
|
|
482
|
-
else:
|
|
483
|
-
color = "white"
|
|
484
|
-
label = task_name
|
|
485
|
-
|
|
486
|
-
tree = Tree(f"[{color}]{label}[/{color}]")
|
|
436
|
+
tree = Tree(task_name)
|
|
487
437
|
|
|
488
438
|
# Add dependencies
|
|
489
439
|
for dep in dep_tree.get("deps", []):
|
|
490
|
-
dep_tree_obj = _build_rich_tree(dep
|
|
440
|
+
dep_tree_obj = _build_rich_tree(dep)
|
|
491
441
|
tree.add(dep_tree_obj)
|
|
492
442
|
|
|
493
443
|
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,32 +50,123 @@ 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,
|
|
52
135
|
args_dict: dict[str, Any],
|
|
53
136
|
dep_statuses: dict[str, TaskStatus],
|
|
137
|
+
force: bool = False,
|
|
54
138
|
) -> TaskStatus:
|
|
55
139
|
"""Check if a task needs to run.
|
|
56
140
|
|
|
57
141
|
A task executes if ANY of these conditions are met:
|
|
58
|
-
1.
|
|
59
|
-
2.
|
|
60
|
-
3. Any
|
|
61
|
-
4.
|
|
62
|
-
5.
|
|
63
|
-
6.
|
|
142
|
+
1. Force flag is set (--force)
|
|
143
|
+
2. Task definition hash differs from cached state
|
|
144
|
+
3. Any explicit inputs have newer mtime than last_run
|
|
145
|
+
4. Any implicit inputs (from deps) have changed
|
|
146
|
+
5. No cached state exists for this task+args combination
|
|
147
|
+
6. Task has no inputs AND no outputs (always runs)
|
|
148
|
+
7. Different arguments than any cached execution
|
|
64
149
|
|
|
65
150
|
Args:
|
|
66
151
|
task: Task to check
|
|
67
152
|
args_dict: Arguments for this task execution
|
|
68
153
|
dep_statuses: Status of dependencies
|
|
154
|
+
force: If True, ignore freshness and force execution
|
|
69
155
|
|
|
70
156
|
Returns:
|
|
71
157
|
TaskStatus indicating whether task will run and why
|
|
72
158
|
"""
|
|
73
|
-
#
|
|
74
|
-
|
|
159
|
+
# If force flag is set, always run
|
|
160
|
+
if force:
|
|
161
|
+
return TaskStatus(
|
|
162
|
+
task_name=task.name,
|
|
163
|
+
will_run=True,
|
|
164
|
+
reason="forced",
|
|
165
|
+
)
|
|
166
|
+
|
|
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)
|
|
75
170
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
76
171
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
77
172
|
|
|
@@ -135,14 +230,16 @@ class Executor:
|
|
|
135
230
|
self,
|
|
136
231
|
task_name: str,
|
|
137
232
|
args_dict: dict[str, Any] | None = None,
|
|
138
|
-
|
|
233
|
+
force: bool = False,
|
|
234
|
+
only: bool = False,
|
|
139
235
|
) -> dict[str, TaskStatus]:
|
|
140
236
|
"""Execute a task and its dependencies.
|
|
141
237
|
|
|
142
238
|
Args:
|
|
143
239
|
task_name: Name of task to execute
|
|
144
240
|
args_dict: Arguments to pass to the task
|
|
145
|
-
|
|
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)
|
|
146
243
|
|
|
147
244
|
Returns:
|
|
148
245
|
Dictionary of task names to their execution status
|
|
@@ -153,8 +250,17 @@ class Executor:
|
|
|
153
250
|
if args_dict is None:
|
|
154
251
|
args_dict = {}
|
|
155
252
|
|
|
253
|
+
# When only=True, force execution (ignore freshness)
|
|
254
|
+
if only:
|
|
255
|
+
force = True
|
|
256
|
+
|
|
156
257
|
# Resolve execution order
|
|
157
|
-
|
|
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)
|
|
158
264
|
|
|
159
265
|
# Check status of all tasks
|
|
160
266
|
statuses: dict[str, TaskStatus] = {}
|
|
@@ -167,12 +273,9 @@ class Executor:
|
|
|
167
273
|
# Determine task-specific args (only for target task)
|
|
168
274
|
task_args = args_dict if name == task_name else {}
|
|
169
275
|
|
|
170
|
-
status = self.check_task_status(task, task_args, dep_statuses)
|
|
276
|
+
status = self.check_task_status(task, task_args, dep_statuses, force=force)
|
|
171
277
|
statuses[name] = status
|
|
172
278
|
|
|
173
|
-
if dry_run:
|
|
174
|
-
return statuses
|
|
175
|
-
|
|
176
279
|
# Execute tasks that need to run
|
|
177
280
|
for name in execution_order:
|
|
178
281
|
status = statuses[name]
|
|
@@ -207,21 +310,116 @@ class Executor:
|
|
|
207
310
|
# Determine working directory
|
|
208
311
|
working_dir = self.recipe.project_root / task.working_dir
|
|
209
312
|
|
|
313
|
+
# Resolve environment for this task
|
|
314
|
+
shell, shell_args, preamble = self._resolve_environment(task)
|
|
315
|
+
|
|
210
316
|
# Execute command
|
|
211
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
|
+
"""
|
|
212
343
|
try:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
344
|
+
# Build command: shell + args + cmd
|
|
345
|
+
full_cmd = [shell] + shell_args + [cmd]
|
|
346
|
+
subprocess.run(
|
|
347
|
+
full_cmd,
|
|
216
348
|
cwd=working_dir,
|
|
217
349
|
check=True,
|
|
218
350
|
capture_output=False,
|
|
219
351
|
)
|
|
220
352
|
except subprocess.CalledProcessError as e:
|
|
221
|
-
raise ExecutionError(
|
|
353
|
+
raise ExecutionError(
|
|
354
|
+
f"Task '{task_name}' failed with exit code {e.returncode}"
|
|
355
|
+
)
|
|
222
356
|
|
|
223
|
-
|
|
224
|
-
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
|
|
225
423
|
|
|
226
424
|
def _substitute_args(self, cmd: str, args_dict: dict[str, Any]) -> str:
|
|
227
425
|
"""Substitute arguments in command string.
|
|
@@ -339,8 +537,9 @@ class Executor:
|
|
|
339
537
|
task: Task that was executed
|
|
340
538
|
args_dict: Arguments used for execution
|
|
341
539
|
"""
|
|
342
|
-
# Compute hashes
|
|
343
|
-
|
|
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)
|
|
344
543
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
345
544
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
346
545
|
|
tasktree/hasher.py
CHANGED
|
@@ -5,7 +5,7 @@ import json
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str]) -> str:
|
|
8
|
+
def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str], env: str = "") -> str:
|
|
9
9
|
"""Compute task definition hash.
|
|
10
10
|
|
|
11
11
|
The hash includes:
|
|
@@ -13,6 +13,7 @@ def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str]) -
|
|
|
13
13
|
- outputs: Declared output files
|
|
14
14
|
- working_dir: Execution directory
|
|
15
15
|
- args: Parameter definitions (names and types)
|
|
16
|
+
- env: Environment name (effective environment after resolution)
|
|
16
17
|
|
|
17
18
|
The hash excludes:
|
|
18
19
|
- deps: Only affects scheduling order
|
|
@@ -24,6 +25,7 @@ def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str]) -
|
|
|
24
25
|
outputs: List of output glob patterns
|
|
25
26
|
working_dir: Working directory for execution
|
|
26
27
|
args: List of argument definitions
|
|
28
|
+
env: Environment name (empty string for platform default)
|
|
27
29
|
|
|
28
30
|
Returns:
|
|
29
31
|
8-character hex hash string
|
|
@@ -34,6 +36,7 @@ def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str]) -
|
|
|
34
36
|
"outputs": sorted(outputs), # Sort for stability
|
|
35
37
|
"working_dir": working_dir,
|
|
36
38
|
"args": sorted(args), # Sort for stability
|
|
39
|
+
"env": env, # Include effective environment
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
# Serialize to JSON with sorted keys for deterministic hashing
|
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):
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasktree
|
|
3
|
-
Version: 0.0.
|
|
4
|
-
Summary: A task automation tool with
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: A task automation tool with incremental execution
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: click>=8.1.0
|
|
7
7
|
Requires-Dist: colorama>=0.4.6
|
|
@@ -14,9 +14,9 @@ Description-Content-Type: text/markdown
|
|
|
14
14
|
|
|
15
15
|
# Task Tree (tt)
|
|
16
16
|
|
|
17
|
-
[](https://github.com/kevinchannon/task-tree/actions/workflows/test.yml)
|
|
18
18
|
|
|
19
|
-
A task automation tool that combines simple command execution with
|
|
19
|
+
A task automation tool that combines simple command execution with dependency tracking and incremental execution.
|
|
20
20
|
|
|
21
21
|
## Installation
|
|
22
22
|
|
|
@@ -31,13 +31,13 @@ pipx install tasktree
|
|
|
31
31
|
For the latest unreleased version from GitHub:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
pipx install git+https://github.com/kevinchannon/
|
|
34
|
+
pipx install git+https://github.com/kevinchannon/task-tree.git
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
Or to install from a local clone:
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
git clone https://github.com/kevinchannon/
|
|
40
|
+
git clone https://github.com/kevinchannon/task-tree.git
|
|
41
41
|
cd tasktree
|
|
42
42
|
pipx install .
|
|
43
43
|
```
|
|
@@ -72,11 +72,12 @@ tt --list # Show all available tasks
|
|
|
72
72
|
|
|
73
73
|
Task Tree only runs tasks when necessary. A task executes if:
|
|
74
74
|
|
|
75
|
-
- Its definition (command, outputs, working directory) has changed
|
|
75
|
+
- Its definition (command, outputs, working directory, environment) has changed
|
|
76
76
|
- Any input files have changed since the last run
|
|
77
77
|
- Any dependencies have re-run
|
|
78
78
|
- It has never been executed before
|
|
79
79
|
- It has no inputs or outputs (always runs)
|
|
80
|
+
- The execution environment has changed (CLI override or environment config change)
|
|
80
81
|
|
|
81
82
|
### Automatic Input Inheritance
|
|
82
83
|
|
|
@@ -105,17 +106,25 @@ All state lives in `.tasktree-state` at your project root. Stale entries are aut
|
|
|
105
106
|
```yaml
|
|
106
107
|
task-name:
|
|
107
108
|
desc: Human-readable description (optional)
|
|
108
|
-
deps: [other-task]
|
|
109
|
-
inputs: [src/**/*.go]
|
|
110
|
-
outputs: [dist/binary]
|
|
111
|
-
working_dir: subproject/
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
deps: [other-task] # Task dependencies
|
|
110
|
+
inputs: [src/**/*.go] # Explicit input files (glob patterns)
|
|
111
|
+
outputs: [dist/binary] # Output files (glob patterns)
|
|
112
|
+
working_dir: subproject/ # Execution directory (default: project root)
|
|
113
|
+
env: bash-strict # Execution environment (optional)
|
|
114
|
+
args: [param1, param2:path=default] # Task parameters
|
|
115
|
+
cmd: go build -o dist/binary # Command to execute
|
|
114
116
|
```
|
|
115
117
|
|
|
116
118
|
### Commands
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
**Single-line commands** are executed directly via the configured shell:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
build:
|
|
124
|
+
cmd: cargo build --release
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Multi-line commands** are written to temporary script files for proper execution:
|
|
119
128
|
|
|
120
129
|
```yaml
|
|
121
130
|
deploy:
|
|
@@ -125,7 +134,9 @@ deploy:
|
|
|
125
134
|
rsync -av dist/ server:/opt/app/
|
|
126
135
|
```
|
|
127
136
|
|
|
128
|
-
|
|
137
|
+
Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
|
|
138
|
+
|
|
139
|
+
Or use folded blocks for long single-line commands:
|
|
129
140
|
|
|
130
141
|
```yaml
|
|
131
142
|
compile:
|
|
@@ -136,6 +147,58 @@ compile:
|
|
|
136
147
|
-L lib -lm
|
|
137
148
|
```
|
|
138
149
|
|
|
150
|
+
### Execution Environments
|
|
151
|
+
|
|
152
|
+
Configure custom shell environments for task execution:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
environments:
|
|
156
|
+
default: bash-strict
|
|
157
|
+
|
|
158
|
+
bash-strict:
|
|
159
|
+
shell: bash
|
|
160
|
+
args: ['-c'] # For single-line: bash -c "command"
|
|
161
|
+
preamble: | # For multi-line: prepended to script
|
|
162
|
+
set -euo pipefail
|
|
163
|
+
|
|
164
|
+
python:
|
|
165
|
+
shell: python
|
|
166
|
+
args: ['-c']
|
|
167
|
+
|
|
168
|
+
powershell:
|
|
169
|
+
shell: powershell
|
|
170
|
+
args: ['-ExecutionPolicy', 'Bypass', '-Command']
|
|
171
|
+
preamble: |
|
|
172
|
+
$ErrorActionPreference = 'Stop'
|
|
173
|
+
|
|
174
|
+
tasks:
|
|
175
|
+
build:
|
|
176
|
+
# Uses 'default' environment (bash-strict)
|
|
177
|
+
cmd: cargo build --release
|
|
178
|
+
|
|
179
|
+
analyze:
|
|
180
|
+
env: python
|
|
181
|
+
cmd: |
|
|
182
|
+
import sys
|
|
183
|
+
print(f"Analyzing with Python {sys.version}")
|
|
184
|
+
# ... analysis code ...
|
|
185
|
+
|
|
186
|
+
windows-task:
|
|
187
|
+
env: powershell
|
|
188
|
+
cmd: |
|
|
189
|
+
Compress-Archive -Path dist/* -DestinationPath package.zip
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Environment resolution priority:**
|
|
193
|
+
1. CLI override: `tt --env python build`
|
|
194
|
+
2. Task's `env` field
|
|
195
|
+
3. Recipe's `default` environment
|
|
196
|
+
4. Platform default (bash on Unix, cmd on Windows)
|
|
197
|
+
|
|
198
|
+
**Platform defaults** when no environments are configured:
|
|
199
|
+
- **Unix/macOS**: bash with `-c` args
|
|
200
|
+
- **Windows**: cmd with `/c` args
|
|
201
|
+
|
|
139
202
|
### Parameterised Tasks
|
|
140
203
|
|
|
141
204
|
Tasks can accept arguments with optional defaults:
|
|
@@ -202,12 +265,19 @@ Input and output patterns support standard glob syntax:
|
|
|
202
265
|
|
|
203
266
|
### How State Works
|
|
204
267
|
|
|
205
|
-
Each task is identified by a hash of its definition
|
|
268
|
+
Each task is identified by a hash of its definition. The hash includes:
|
|
269
|
+
|
|
270
|
+
- Command to execute
|
|
271
|
+
- Output patterns
|
|
272
|
+
- Working directory
|
|
273
|
+
- Argument definitions
|
|
274
|
+
- Execution environment
|
|
206
275
|
|
|
276
|
+
State tracks:
|
|
207
277
|
- When the task last ran
|
|
208
278
|
- Timestamps of input files at that time
|
|
209
279
|
|
|
210
|
-
Tasks are re-run when their definition changes
|
|
280
|
+
Tasks are re-run when their definition changes, inputs are newer than the last run, or the environment changes.
|
|
211
281
|
|
|
212
282
|
### What's Not In The Hash
|
|
213
283
|
|
|
@@ -222,6 +292,72 @@ Changes to these don't invalidate cached state:
|
|
|
222
292
|
|
|
223
293
|
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
294
|
|
|
295
|
+
## Command-Line Options
|
|
296
|
+
|
|
297
|
+
Task Tree provides several command-line options for controlling task execution:
|
|
298
|
+
|
|
299
|
+
### Execution Control
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
# Force re-run (ignore freshness checks)
|
|
303
|
+
tt --force build
|
|
304
|
+
tt -f build
|
|
305
|
+
|
|
306
|
+
# Run only the specified task, skip dependencies (implies --force)
|
|
307
|
+
tt --only deploy
|
|
308
|
+
tt -o deploy
|
|
309
|
+
|
|
310
|
+
# Override environment for all tasks
|
|
311
|
+
tt --env python analyze
|
|
312
|
+
tt -e powershell build
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Information Commands
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
# List all available tasks
|
|
319
|
+
tt --list
|
|
320
|
+
tt -l
|
|
321
|
+
|
|
322
|
+
# Show detailed task definition
|
|
323
|
+
tt --show build
|
|
324
|
+
|
|
325
|
+
# Show dependency tree (without execution)
|
|
326
|
+
tt --tree deploy
|
|
327
|
+
|
|
328
|
+
# Show version
|
|
329
|
+
tt --version
|
|
330
|
+
tt -v
|
|
331
|
+
|
|
332
|
+
# Create a blank recipe file
|
|
333
|
+
tt --init
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### State Management
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
# Remove state file (reset task cache)
|
|
340
|
+
tt --clean
|
|
341
|
+
tt --clean-state
|
|
342
|
+
tt --reset
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Common Workflows
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
# Fresh build of everything
|
|
349
|
+
tt --force build
|
|
350
|
+
|
|
351
|
+
# Run a task without rebuilding dependencies
|
|
352
|
+
tt --only test
|
|
353
|
+
|
|
354
|
+
# Test with a different shell/environment
|
|
355
|
+
tt --env python test
|
|
356
|
+
|
|
357
|
+
# Force rebuild and deploy
|
|
358
|
+
tt --force deploy production
|
|
359
|
+
```
|
|
360
|
+
|
|
225
361
|
## Example: Full Build Pipeline
|
|
226
362
|
|
|
227
363
|
```yaml
|
|
@@ -297,7 +433,7 @@ State file uses JSON format for simplicity and standard library compatibility.
|
|
|
297
433
|
|
|
298
434
|
```bash
|
|
299
435
|
# Clone repository
|
|
300
|
-
git clone https://github.com/kevinchannon/
|
|
436
|
+
git clone https://github.com/kevinchannon/task-tree.git
|
|
301
437
|
cd tasktree
|
|
302
438
|
|
|
303
439
|
# Install uv (if not already installed)
|
|
@@ -363,8 +499,8 @@ git push origin v1.0.0
|
|
|
363
499
|
- Publish to PyPI
|
|
364
500
|
|
|
365
501
|
4. Verify the release:
|
|
366
|
-
- GitHub: https://github.com/kevinchannon/
|
|
367
|
-
- PyPI: https://pypi.org/
|
|
502
|
+
- GitHub: https://github.com/kevinchannon/task-tree/releases
|
|
503
|
+
- PyPI: https://pypi.org/kevinchannon/tasktree/
|
|
368
504
|
- Test: `pipx install --force tasktree`
|
|
369
505
|
|
|
370
506
|
### Version Numbering
|
|
@@ -372,16 +508,4 @@ git push origin v1.0.0
|
|
|
372
508
|
Follow semantic versioning:
|
|
373
509
|
- `v1.0.0` - Major release (breaking changes)
|
|
374
510
|
- `v1.1.0` - Minor release (new features, backward compatible)
|
|
375
|
-
- `v1.1.1` - Patch release (bug fixes)
|
|
376
|
-
|
|
377
|
-
### PyPI Trusted Publishing Setup
|
|
378
|
-
|
|
379
|
-
Before the first release, configure trusted publishing on PyPI:
|
|
380
|
-
|
|
381
|
-
1. Go to https://pypi.org/manage/account/publishing/
|
|
382
|
-
2. Add a new publisher:
|
|
383
|
-
- **PyPI Project Name**: `tasktree`
|
|
384
|
-
- **Owner**: `kevinchannon`
|
|
385
|
-
- **Repository name**: `tasktree`
|
|
386
|
-
- **Workflow name**: `release.yml`
|
|
387
|
-
- **Environment name**: (leave blank)
|
|
511
|
+
- `v1.1.1` - Patch release (bug fixes)
|
|
@@ -0,0 +1,13 @@
|
|
|
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,,
|
tasktree-0.0.1.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
-
tasktree/cli.py,sha256=hI9jqHE2YIb_6lYUjxVobLJOE88kOlunfAg52TsD1sQ,15681
|
|
3
|
-
tasktree/executor.py,sha256=TH59xTR6MNBEek9Q8yjYhgC-h0H4mjNsP-yjAcVgS-E,11838
|
|
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.1.dist-info/METADATA,sha256=aE9ihAgGTRvrNh7TarEA2vEFftKg3FV1DVlMAN0M9LU,9522
|
|
11
|
-
tasktree-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
-
tasktree-0.0.1.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
13
|
-
tasktree-0.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|