tasktree 0.0.2__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 +35 -89
- tasktree/executor.py +202 -16
- tasktree/hasher.py +4 -1
- tasktree/parser.py +108 -5
- {tasktree-0.0.2.dist-info → tasktree-0.0.3.dist-info}/METADATA +148 -12
- tasktree-0.0.3.dist-info/RECORD +13 -0
- tasktree-0.0.2.dist-info/RECORD +0 -13
- {tasktree-0.0.2.dist-info → tasktree-0.0.3.dist-info}/WHEEL +0 -0
- {tasktree-0.0.2.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
|
),
|
|
@@ -239,6 +188,12 @@ def main(
|
|
|
239
188
|
force: Optional[bool] = typer.Option(
|
|
240
189
|
None, "--force", "-f", help="Force re-run all tasks (ignore freshness)"
|
|
241
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
|
+
),
|
|
242
197
|
task_args: Optional[List[str]] = typer.Argument(
|
|
243
198
|
None, help="Task name and arguments"
|
|
244
199
|
),
|
|
@@ -270,11 +225,6 @@ def main(
|
|
|
270
225
|
_show_tree(tree)
|
|
271
226
|
raise typer.Exit()
|
|
272
227
|
|
|
273
|
-
# Handle dry-run option
|
|
274
|
-
if dry_run:
|
|
275
|
-
_dry_run(dry_run)
|
|
276
|
-
raise typer.Exit()
|
|
277
|
-
|
|
278
228
|
# Handle init option
|
|
279
229
|
if init:
|
|
280
230
|
_init_recipe()
|
|
@@ -287,7 +237,9 @@ def main(
|
|
|
287
237
|
|
|
288
238
|
# Handle task execution
|
|
289
239
|
if task_args:
|
|
290
|
-
|
|
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)
|
|
291
243
|
else:
|
|
292
244
|
# No arguments - show available tasks
|
|
293
245
|
recipe = _get_recipe()
|
|
@@ -335,12 +287,14 @@ def _get_recipe() -> Recipe | None:
|
|
|
335
287
|
raise typer.Exit(1)
|
|
336
288
|
|
|
337
289
|
|
|
338
|
-
def _execute_dynamic_task(args: list[str], force: bool = False) -> None:
|
|
290
|
+
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: str | None = None) -> None:
|
|
339
291
|
"""Execute a task specified by name with arguments.
|
|
340
292
|
|
|
341
293
|
Args:
|
|
342
294
|
args: Command line arguments (task name and task arguments)
|
|
343
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
|
|
344
298
|
"""
|
|
345
299
|
if not args:
|
|
346
300
|
return
|
|
@@ -353,6 +307,17 @@ def _execute_dynamic_task(args: list[str], force: bool = False) -> None:
|
|
|
353
307
|
console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
|
|
354
308
|
raise typer.Exit(1)
|
|
355
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
|
+
|
|
356
321
|
task = recipe.get_task(task_name)
|
|
357
322
|
if task is None:
|
|
358
323
|
console.print(f"[red]Task not found: {task_name}[/red]")
|
|
@@ -364,20 +329,20 @@ def _execute_dynamic_task(args: list[str], force: bool = False) -> None:
|
|
|
364
329
|
# Parse task arguments
|
|
365
330
|
args_dict = _parse_task_args(task.args, task_args)
|
|
366
331
|
|
|
367
|
-
#
|
|
332
|
+
# Create executor and state manager
|
|
368
333
|
state = StateManager(recipe.project_root)
|
|
369
334
|
state.load()
|
|
335
|
+
executor = Executor(recipe, state)
|
|
336
|
+
|
|
337
|
+
# Prune state before execution (compute hashes with effective environment)
|
|
370
338
|
valid_hashes = {
|
|
371
|
-
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))
|
|
372
340
|
for t in recipe.tasks.values()
|
|
373
341
|
}
|
|
374
342
|
state.prune(valid_hashes)
|
|
375
343
|
state.save()
|
|
376
|
-
|
|
377
|
-
# Execute task
|
|
378
|
-
executor = Executor(recipe, state)
|
|
379
344
|
try:
|
|
380
|
-
executor.execute_task(task_name, args_dict,
|
|
345
|
+
executor.execute_task(task_name, args_dict, force=force, only=only)
|
|
381
346
|
console.print(f"[green]✓ Task '{task_name}' completed successfully[/green]")
|
|
382
347
|
except Exception as e:
|
|
383
348
|
console.print(f"[red]✗ Task '{task_name}' failed: {e}[/red]")
|
|
@@ -458,40 +423,21 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
458
423
|
return args_dict
|
|
459
424
|
|
|
460
425
|
|
|
461
|
-
def _build_rich_tree(dep_tree: dict
|
|
462
|
-
"""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.
|
|
463
428
|
|
|
464
429
|
Args:
|
|
465
430
|
dep_tree: Dependency tree structure
|
|
466
|
-
statuses: Task execution statuses
|
|
467
431
|
|
|
468
432
|
Returns:
|
|
469
433
|
Rich Tree for display
|
|
470
434
|
"""
|
|
471
435
|
task_name = dep_tree["name"]
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
# Determine color based on status
|
|
475
|
-
if status:
|
|
476
|
-
if status.will_run:
|
|
477
|
-
if status.reason == "dependency_triggered":
|
|
478
|
-
color = "yellow"
|
|
479
|
-
label = f"{task_name} (triggered by dependency)"
|
|
480
|
-
else:
|
|
481
|
-
color = "red"
|
|
482
|
-
label = f"{task_name} (stale: {status.reason})"
|
|
483
|
-
else:
|
|
484
|
-
color = "green"
|
|
485
|
-
label = f"{task_name} (fresh)"
|
|
486
|
-
else:
|
|
487
|
-
color = "white"
|
|
488
|
-
label = task_name
|
|
489
|
-
|
|
490
|
-
tree = Tree(f"[{color}]{label}[/{color}]")
|
|
436
|
+
tree = Tree(task_name)
|
|
491
437
|
|
|
492
438
|
# Add dependencies
|
|
493
439
|
for dep in dep_tree.get("deps", []):
|
|
494
|
-
dep_tree_obj = _build_rich_tree(dep
|
|
440
|
+
dep_tree_obj = _build_rich_tree(dep)
|
|
495
441
|
tree.add(dep_tree_obj)
|
|
496
442
|
|
|
497
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,6 +50,85 @@ class Executor:
|
|
|
46
50
|
self.recipe = recipe
|
|
47
51
|
self.state = state_manager
|
|
48
52
|
|
|
53
|
+
def _get_platform_default_environment(self) -> tuple[str, list[str]]:
|
|
54
|
+
"""Get default shell and args for current platform.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (shell, args) for platform default
|
|
58
|
+
"""
|
|
59
|
+
is_windows = platform.system() == "Windows"
|
|
60
|
+
if is_windows:
|
|
61
|
+
return ("cmd", ["/c"])
|
|
62
|
+
else:
|
|
63
|
+
return ("bash", ["-c"])
|
|
64
|
+
|
|
65
|
+
def _get_effective_env_name(self, task: Task) -> str:
|
|
66
|
+
"""Get the effective environment name for a task.
|
|
67
|
+
|
|
68
|
+
Resolution order:
|
|
69
|
+
1. Recipe's global_env_override (from CLI --env)
|
|
70
|
+
2. Task's explicit env field
|
|
71
|
+
3. Recipe's default_env
|
|
72
|
+
4. Empty string (for platform default)
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
task: Task to get environment name for
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Environment name (empty string if using platform default)
|
|
79
|
+
"""
|
|
80
|
+
# Check for global override first
|
|
81
|
+
if self.recipe.global_env_override:
|
|
82
|
+
return self.recipe.global_env_override
|
|
83
|
+
|
|
84
|
+
# Use task's env
|
|
85
|
+
if task.env:
|
|
86
|
+
return task.env
|
|
87
|
+
|
|
88
|
+
# Use recipe default
|
|
89
|
+
if self.recipe.default_env:
|
|
90
|
+
return self.recipe.default_env
|
|
91
|
+
|
|
92
|
+
# Platform default (no env name)
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
def _resolve_environment(self, task: Task) -> tuple[str, list[str], str]:
|
|
96
|
+
"""Resolve which environment to use for a task.
|
|
97
|
+
|
|
98
|
+
Resolution order:
|
|
99
|
+
1. Recipe's global_env_override (from CLI --env)
|
|
100
|
+
2. Task's explicit env field
|
|
101
|
+
3. Recipe's default_env
|
|
102
|
+
4. Platform default (bash on Unix, cmd on Windows)
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
task: Task to resolve environment for
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Tuple of (shell, args, preamble)
|
|
109
|
+
"""
|
|
110
|
+
# Check for global override first
|
|
111
|
+
env_name = self.recipe.global_env_override
|
|
112
|
+
|
|
113
|
+
# If no global override, use task's env
|
|
114
|
+
if not env_name:
|
|
115
|
+
env_name = task.env
|
|
116
|
+
|
|
117
|
+
# If no explicit env, try recipe default
|
|
118
|
+
if not env_name and self.recipe.default_env:
|
|
119
|
+
env_name = self.recipe.default_env
|
|
120
|
+
|
|
121
|
+
# If we have an env name, look it up
|
|
122
|
+
if env_name:
|
|
123
|
+
env = self.recipe.get_environment(env_name)
|
|
124
|
+
if env:
|
|
125
|
+
return (env.shell, env.args, env.preamble)
|
|
126
|
+
# If env not found, fall through to platform default
|
|
127
|
+
|
|
128
|
+
# Use platform default
|
|
129
|
+
shell, args = self._get_platform_default_environment()
|
|
130
|
+
return (shell, args, "")
|
|
131
|
+
|
|
49
132
|
def check_task_status(
|
|
50
133
|
self,
|
|
51
134
|
task: Task,
|
|
@@ -81,8 +164,9 @@ class Executor:
|
|
|
81
164
|
reason="forced",
|
|
82
165
|
)
|
|
83
166
|
|
|
84
|
-
# Compute hashes
|
|
85
|
-
|
|
167
|
+
# Compute hashes (include effective environment)
|
|
168
|
+
effective_env = self._get_effective_env_name(task)
|
|
169
|
+
task_hash = hash_task(task.cmd, task.outputs, task.working_dir, task.args, effective_env)
|
|
86
170
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
87
171
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
88
172
|
|
|
@@ -146,16 +230,16 @@ class Executor:
|
|
|
146
230
|
self,
|
|
147
231
|
task_name: str,
|
|
148
232
|
args_dict: dict[str, Any] | None = None,
|
|
149
|
-
dry_run: bool = False,
|
|
150
233
|
force: bool = False,
|
|
234
|
+
only: bool = False,
|
|
151
235
|
) -> dict[str, TaskStatus]:
|
|
152
236
|
"""Execute a task and its dependencies.
|
|
153
237
|
|
|
154
238
|
Args:
|
|
155
239
|
task_name: Name of task to execute
|
|
156
240
|
args_dict: Arguments to pass to the task
|
|
157
|
-
dry_run: If True, only check what would run without executing
|
|
158
241
|
force: If True, ignore freshness and re-run all tasks
|
|
242
|
+
only: If True, run only the specified task without dependencies (implies force=True)
|
|
159
243
|
|
|
160
244
|
Returns:
|
|
161
245
|
Dictionary of task names to their execution status
|
|
@@ -166,8 +250,17 @@ class Executor:
|
|
|
166
250
|
if args_dict is None:
|
|
167
251
|
args_dict = {}
|
|
168
252
|
|
|
253
|
+
# When only=True, force execution (ignore freshness)
|
|
254
|
+
if only:
|
|
255
|
+
force = True
|
|
256
|
+
|
|
169
257
|
# Resolve execution order
|
|
170
|
-
|
|
258
|
+
if only:
|
|
259
|
+
# Only execute the target task, skip dependencies
|
|
260
|
+
execution_order = [task_name]
|
|
261
|
+
else:
|
|
262
|
+
# Execute task and all dependencies
|
|
263
|
+
execution_order = resolve_execution_order(self.recipe, task_name)
|
|
171
264
|
|
|
172
265
|
# Check status of all tasks
|
|
173
266
|
statuses: dict[str, TaskStatus] = {}
|
|
@@ -183,9 +276,6 @@ class Executor:
|
|
|
183
276
|
status = self.check_task_status(task, task_args, dep_statuses, force=force)
|
|
184
277
|
statuses[name] = status
|
|
185
278
|
|
|
186
|
-
if dry_run:
|
|
187
|
-
return statuses
|
|
188
|
-
|
|
189
279
|
# Execute tasks that need to run
|
|
190
280
|
for name in execution_order:
|
|
191
281
|
status = statuses[name]
|
|
@@ -220,21 +310,116 @@ class Executor:
|
|
|
220
310
|
# Determine working directory
|
|
221
311
|
working_dir = self.recipe.project_root / task.working_dir
|
|
222
312
|
|
|
313
|
+
# Resolve environment for this task
|
|
314
|
+
shell, shell_args, preamble = self._resolve_environment(task)
|
|
315
|
+
|
|
223
316
|
# Execute command
|
|
224
317
|
print(f"Running: {task.name}")
|
|
318
|
+
|
|
319
|
+
# Detect multi-line commands (ignore trailing newlines from YAML folded blocks)
|
|
320
|
+
if "\n" in cmd.rstrip():
|
|
321
|
+
self._run_multiline_command(cmd, working_dir, task.name, shell, preamble)
|
|
322
|
+
else:
|
|
323
|
+
self._run_single_line_command(cmd, working_dir, task.name, shell, shell_args)
|
|
324
|
+
|
|
325
|
+
# Update state
|
|
326
|
+
self._update_state(task, args_dict)
|
|
327
|
+
|
|
328
|
+
def _run_single_line_command(
|
|
329
|
+
self, cmd: str, working_dir: Path, task_name: str, shell: str, shell_args: list[str]
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Execute a single-line command via shell.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
cmd: Command string
|
|
335
|
+
working_dir: Working directory
|
|
336
|
+
task_name: Task name (for error messages)
|
|
337
|
+
shell: Shell executable to use
|
|
338
|
+
shell_args: Arguments to pass to shell
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ExecutionError: If command execution fails
|
|
342
|
+
"""
|
|
225
343
|
try:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
344
|
+
# Build command: shell + args + cmd
|
|
345
|
+
full_cmd = [shell] + shell_args + [cmd]
|
|
346
|
+
subprocess.run(
|
|
347
|
+
full_cmd,
|
|
229
348
|
cwd=working_dir,
|
|
230
349
|
check=True,
|
|
231
350
|
capture_output=False,
|
|
232
351
|
)
|
|
233
352
|
except subprocess.CalledProcessError as e:
|
|
234
|
-
raise ExecutionError(
|
|
353
|
+
raise ExecutionError(
|
|
354
|
+
f"Task '{task_name}' failed with exit code {e.returncode}"
|
|
355
|
+
)
|
|
235
356
|
|
|
236
|
-
|
|
237
|
-
self
|
|
357
|
+
def _run_multiline_command(
|
|
358
|
+
self, cmd: str, working_dir: Path, task_name: str, shell: str, preamble: str
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Execute a multi-line command via temporary script file.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
cmd: Multi-line command string
|
|
364
|
+
working_dir: Working directory
|
|
365
|
+
task_name: Task name (for error messages)
|
|
366
|
+
shell: Shell to use for script execution
|
|
367
|
+
preamble: Preamble text to prepend to script
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
ExecutionError: If command execution fails
|
|
371
|
+
"""
|
|
372
|
+
# Determine file extension based on platform
|
|
373
|
+
is_windows = platform.system() == "Windows"
|
|
374
|
+
script_ext = ".bat" if is_windows else ".sh"
|
|
375
|
+
|
|
376
|
+
# Create temporary script file
|
|
377
|
+
with tempfile.NamedTemporaryFile(
|
|
378
|
+
mode="w",
|
|
379
|
+
suffix=script_ext,
|
|
380
|
+
delete=False,
|
|
381
|
+
) as script_file:
|
|
382
|
+
script_path = script_file.name
|
|
383
|
+
|
|
384
|
+
# On Unix/macOS, add shebang if not present
|
|
385
|
+
if not is_windows and not cmd.startswith("#!"):
|
|
386
|
+
# Use the configured shell in shebang
|
|
387
|
+
shebang = f"#!/usr/bin/env {shell}\n"
|
|
388
|
+
script_file.write(shebang)
|
|
389
|
+
|
|
390
|
+
# Add preamble if provided
|
|
391
|
+
if preamble:
|
|
392
|
+
script_file.write(preamble)
|
|
393
|
+
if not preamble.endswith("\n"):
|
|
394
|
+
script_file.write("\n")
|
|
395
|
+
|
|
396
|
+
# Write command to file
|
|
397
|
+
script_file.write(cmd)
|
|
398
|
+
script_file.flush()
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
# Make executable on Unix/macOS
|
|
402
|
+
if not is_windows:
|
|
403
|
+
os.chmod(script_path, os.stat(script_path).st_mode | stat.S_IEXEC)
|
|
404
|
+
|
|
405
|
+
# Execute script file
|
|
406
|
+
try:
|
|
407
|
+
subprocess.run(
|
|
408
|
+
[script_path],
|
|
409
|
+
cwd=working_dir,
|
|
410
|
+
check=True,
|
|
411
|
+
capture_output=False,
|
|
412
|
+
)
|
|
413
|
+
except subprocess.CalledProcessError as e:
|
|
414
|
+
raise ExecutionError(
|
|
415
|
+
f"Task '{task_name}' failed with exit code {e.returncode}"
|
|
416
|
+
)
|
|
417
|
+
finally:
|
|
418
|
+
# Clean up temporary file
|
|
419
|
+
try:
|
|
420
|
+
os.unlink(script_path)
|
|
421
|
+
except OSError:
|
|
422
|
+
pass # Ignore cleanup errors
|
|
238
423
|
|
|
239
424
|
def _substitute_args(self, cmd: str, args_dict: dict[str, Any]) -> str:
|
|
240
425
|
"""Substitute arguments in command string.
|
|
@@ -352,8 +537,9 @@ class Executor:
|
|
|
352
537
|
task: Task that was executed
|
|
353
538
|
args_dict: Arguments used for execution
|
|
354
539
|
"""
|
|
355
|
-
# Compute hashes
|
|
356
|
-
|
|
540
|
+
# Compute hashes (include effective environment)
|
|
541
|
+
effective_env = self._get_effective_env_name(task)
|
|
542
|
+
task_hash = hash_task(task.cmd, task.outputs, task.working_dir, task.args, effective_env)
|
|
357
543
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
358
544
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
359
545
|
|
tasktree/hasher.py
CHANGED
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasktree
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: A task automation tool with incremental execution
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: click>=8.1.0
|
|
@@ -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:
|
|
206
269
|
|
|
270
|
+
- Command to execute
|
|
271
|
+
- Output patterns
|
|
272
|
+
- Working directory
|
|
273
|
+
- Argument definitions
|
|
274
|
+
- Execution environment
|
|
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
|
|
@@ -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.2.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
-
tasktree/cli.py,sha256=VZGB8FJ8glZLn_z2bjSLLbmGRfFZ5Zh4nc1cYDLjGck,15904
|
|
3
|
-
tasktree/executor.py,sha256=ZM-qEsgvjfRz77GEZzxF29L3I2d8Uz5W1ev7Mox9Vnc,12287
|
|
4
|
-
tasktree/graph.py,sha256=9ngfg93y7EkOIN_lUQa0u-JhnwiMN1UdQQvIFw8RYCE,4181
|
|
5
|
-
tasktree/hasher.py,sha256=jvXBvIfFH9g6AOBHHd012v8nG5JOgVnHH35mIEtVkwc,2133
|
|
6
|
-
tasktree/parser.py,sha256=F2LbB84NinvBQT8zLONxn036R9Rued2y4XB3WGZ8PLk,9150
|
|
7
|
-
tasktree/state.py,sha256=rxKtS3SbsPtAuraHbN807RGWfoYYkQ3pe8CxUstwo2k,3535
|
|
8
|
-
tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
|
|
9
|
-
tasktree/types.py,sha256=wrBzO-Z2ebCTRjWyOWNvuCjqAq-74Zyb9E4FQ4beF38,3751
|
|
10
|
-
tasktree-0.0.2.dist-info/METADATA,sha256=yUoIhImEiwFND5rcCwzVdHosfW6BMvwbvdQgqF8EjnE,9145
|
|
11
|
-
tasktree-0.0.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
-
tasktree-0.0.2.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
13
|
-
tasktree-0.0.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|