tasktree 0.0.2__py3-none-any.whl → 0.0.4__py3-none-any.whl

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