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 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 with freshness indicators."""
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, statuses)
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 intelligent incremental execution.
201
+ """Task Tree - A task automation tool with incremental execution.
244
202
 
245
- Run tasks defined in tasktree.yaml with intelligent dependency tracking
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
- _execute_dynamic_task(task_args)
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
- # Prune state before execution
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, dry_run=False)
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, statuses: dict) -> Tree:
458
- """Build a Rich Tree from dependency tree and statuses.
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
- status = statuses.get(task_name)
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, statuses)
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. Task definition hash differs from cached state
59
- 2. Any explicit inputs have newer mtime than last_run
60
- 3. Any implicit inputs (from deps) have changed
61
- 4. No cached state exists for this task+args combination
62
- 5. Task has no inputs AND no outputs (always runs)
63
- 6. Different arguments than any cached execution
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
- # Compute hashes
74
- task_hash = hash_task(task.cmd, task.outputs, task.working_dir, task.args)
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
- dry_run: bool = False,
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
- dry_run: If True, only check what would run without executing
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
- 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)
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
- result = subprocess.run(
214
- cmd,
215
- shell=True,
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(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
+ )
222
356
 
223
- # Update state
224
- 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
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
- 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)
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 = _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):
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree
3
- Version: 0.0.1
4
- Summary: A task automation tool with intelligent incremental execution
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
- [![Tests](https://github.com/kevinchannon/tasktree/actions/workflows/test.yml/badge.svg)](https://github.com/kevinchannon/tasktree/actions/workflows/test.yml)
17
+ [![Tests](https://github.com/kevinchannon/task-tree/actions/workflows/test.yml/badge.svg)](https://github.com/kevinchannon/task-tree/actions/workflows/test.yml)
18
18
 
19
- A task automation tool that combines simple command execution with intelligent dependency tracking and incremental execution.
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/tasktree.git
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/tasktree.git
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] # 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
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
- Multi-line commands using YAML literal blocks:
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
- Or folded blocks for long single-line commands:
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 (command, outputs, working directory). State tracks:
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 or inputs are newer than the last run.
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/tasktree.git
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/tasktree/releases
367
- - PyPI: https://pypi.org/project/tasktree/
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,,
@@ -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,,