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 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
  ),
@@ -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
- _execute_dynamic_task(task_args, force=force or False)
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
- # Prune state before execution
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, dry_run=False, force=force)
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, statuses: dict) -> Tree:
462
- """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.
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
- 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}]")
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, statuses)
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
- 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
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree
3
- Version: 0.0.2
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] # 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:
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 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
@@ -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=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,,