tasktree 0.0.4__py3-none-any.whl → 0.0.6__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
@@ -26,11 +26,11 @@ app = typer.Typer(
26
26
  console = Console()
27
27
 
28
28
 
29
- def _list_tasks():
29
+ def _list_tasks(tasks_file: Optional[str] = None):
30
30
  """List all available tasks with descriptions."""
31
- recipe = _get_recipe()
31
+ recipe = _get_recipe(tasks_file)
32
32
  if recipe is None:
33
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
33
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
34
34
  raise typer.Exit(1)
35
35
 
36
36
  table = Table(title="Available Tasks")
@@ -45,11 +45,11 @@ def _list_tasks():
45
45
  console.print(table)
46
46
 
47
47
 
48
- def _show_task(task_name: str):
48
+ def _show_task(task_name: str, tasks_file: Optional[str] = None):
49
49
  """Show task definition with syntax highlighting."""
50
- recipe = _get_recipe()
50
+ recipe = _get_recipe(tasks_file)
51
51
  if recipe is None:
52
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
52
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
53
53
  raise typer.Exit(1)
54
54
 
55
55
  task = recipe.get_task(task_name)
@@ -79,17 +79,26 @@ def _show_task(task_name: str):
79
79
  task_dict = task_yaml[task_name]
80
80
  task_yaml[task_name] = {k: v for k, v in task_dict.items() if v}
81
81
 
82
+ # Configure YAML dumper to use literal block style for multiline strings
83
+ def literal_presenter(dumper, data):
84
+ """Use literal block style (|) for strings containing newlines."""
85
+ if '\n' in data:
86
+ return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
87
+ return dumper.represent_scalar('tag:yaml.org,2002:str', data)
88
+
89
+ yaml.add_representer(str, literal_presenter)
90
+
82
91
  # Format and highlight using Rich
83
92
  yaml_str = yaml.dump(task_yaml, default_flow_style=False, sort_keys=False)
84
93
  syntax = Syntax(yaml_str, "yaml", theme="ansi_light", line_numbers=False)
85
94
  console.print(syntax)
86
95
 
87
96
 
88
- def _show_tree(task_name: str):
97
+ def _show_tree(task_name: str, tasks_file: Optional[str] = None):
89
98
  """Show dependency tree structure."""
90
- recipe = _get_recipe()
99
+ recipe = _get_recipe(tasks_file)
91
100
  if recipe is None:
92
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
101
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
93
102
  raise typer.Exit(1)
94
103
 
95
104
  task = recipe.get_task(task_name)
@@ -117,27 +126,28 @@ def _init_recipe():
117
126
  raise typer.Exit(1)
118
127
 
119
128
  template = """# Task Tree Recipe
120
- # See https://github.com/kevinchannon/task-tree for documentation
129
+ # See https://github.com/kevinchannon/tasktree for documentation
121
130
 
122
131
  # Example task definitions:
123
132
 
124
- # build:
125
- # desc: Compile the application
126
- # outputs: [target/release/bin]
127
- # cmd: cargo build --release
128
-
129
- # test:
130
- # desc: Run tests
131
- # deps: [build]
132
- # cmd: cargo test
133
-
134
- # deploy:
135
- # desc: Deploy to environment
136
- # deps: [build]
137
- # args: [environment, region=eu-west-1]
138
- # cmd: |
139
- # echo "Deploying to {{environment}} in {{region}}"
140
- # ./deploy.sh {{environment}} {{region}}
133
+ tasks:
134
+ # build:
135
+ # desc: Compile the application
136
+ # outputs: [target/release/bin]
137
+ # cmd: cargo build --release
138
+
139
+ # test:
140
+ # desc: Run tests
141
+ # deps: [build]
142
+ # cmd: cargo test
143
+
144
+ # deploy:
145
+ # desc: Deploy to environment
146
+ # deps: [build]
147
+ # args: [environment, region=eu-west-1]
148
+ # cmd: |
149
+ # echo "Deploying to {{environment}} in {{region}}"
150
+ # ./deploy.sh {{environment}} {{region}}
141
151
 
142
152
  # Uncomment and modify the examples above to define your tasks
143
153
  """
@@ -168,6 +178,7 @@ def main(
168
178
  list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
169
179
  show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
170
180
  tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
181
+ tasks_file: Optional[str] = typer.Option(None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"),
171
182
  init: Optional[bool] = typer.Option(
172
183
  None, "--init", "-i", help="Create a blank tasktree.yaml"
173
184
  ),
@@ -207,15 +218,15 @@ def main(
207
218
  """
208
219
 
209
220
  if list_opt:
210
- _list_tasks()
221
+ _list_tasks(tasks_file)
211
222
  raise typer.Exit()
212
223
 
213
224
  if show:
214
- _show_task(show)
225
+ _show_task(show, tasks_file)
215
226
  raise typer.Exit()
216
227
 
217
228
  if tree:
218
- _show_tree(tree)
229
+ _show_tree(tree, tasks_file)
219
230
  raise typer.Exit()
220
231
 
221
232
  if init:
@@ -223,17 +234,17 @@ def main(
223
234
  raise typer.Exit()
224
235
 
225
236
  if clean or clean_state or reset:
226
- _clean_state()
237
+ _clean_state(tasks_file)
227
238
  raise typer.Exit()
228
239
 
229
240
  if task_args:
230
241
  # --only implies --force
231
242
  force_execution = force or only or False
232
- _execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env)
243
+ _execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env, tasks_file=tasks_file)
233
244
  else:
234
- recipe = _get_recipe()
245
+ recipe = _get_recipe(tasks_file)
235
246
  if recipe is None:
236
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
247
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
237
248
  console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
238
249
  raise typer.Exit(1)
239
250
 
@@ -244,13 +255,19 @@ def main(
244
255
  console.print("Use [cyan]tt <task-name>[/cyan] to run a task")
245
256
 
246
257
 
247
- def _clean_state() -> None:
258
+ def _clean_state(tasks_file: Optional[str] = None) -> None:
248
259
  """Remove the .tasktree-state file to reset task execution state."""
249
- recipe_path = find_recipe_file()
250
- if recipe_path is None:
251
- console.print("[yellow]No recipe file found[/yellow]")
252
- console.print("State file location depends on recipe file location")
253
- raise typer.Exit(1)
260
+ if tasks_file:
261
+ recipe_path = Path(tasks_file)
262
+ if not recipe_path.exists():
263
+ console.print(f"[red]Recipe file not found: {tasks_file}[/red]")
264
+ raise typer.Exit(1)
265
+ else:
266
+ recipe_path = find_recipe_file()
267
+ if recipe_path is None:
268
+ console.print("[yellow]No recipe file found[/yellow]")
269
+ console.print("State file location depends on recipe file location")
270
+ raise typer.Exit(1)
254
271
 
255
272
  project_root = recipe_path.parent
256
273
  state_path = project_root / ".tasktree-state"
@@ -263,29 +280,48 @@ def _clean_state() -> None:
263
280
  console.print(f"[yellow]No state file found at {state_path}[/yellow]")
264
281
 
265
282
 
266
- def _get_recipe() -> Optional[Recipe]:
267
- """Get parsed recipe or None if not found."""
268
- recipe_path = find_recipe_file()
269
- if recipe_path is None:
270
- return None
283
+ def _get_recipe(recipe_file: Optional[str] = None) -> Optional[Recipe]:
284
+ """Get parsed recipe or None if not found.
285
+
286
+ Args:
287
+ recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
288
+ """
289
+ if recipe_file:
290
+ recipe_path = Path(recipe_file)
291
+ if not recipe_path.exists():
292
+ console.print(f"[red]Recipe file not found: {recipe_file}[/red]")
293
+ raise typer.Exit(1)
294
+ # When explicitly specified, project root is current working directory
295
+ project_root = Path.cwd()
296
+ else:
297
+ try:
298
+ recipe_path = find_recipe_file()
299
+ if recipe_path is None:
300
+ return None
301
+ except ValueError as e:
302
+ # Multiple recipe files found
303
+ console.print(f"[red]{e}[/red]")
304
+ raise typer.Exit(1)
305
+ # When auto-discovered, project root is recipe file's parent
306
+ project_root = None
271
307
 
272
308
  try:
273
- return parse_recipe(recipe_path)
309
+ return parse_recipe(recipe_path, project_root)
274
310
  except Exception as e:
275
311
  console.print(f"[red]Error parsing recipe: {e}[/red]")
276
312
  raise typer.Exit(1)
277
313
 
278
314
 
279
- def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None) -> None:
315
+ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None, tasks_file: Optional[str] = None) -> None:
280
316
  if not args:
281
317
  return
282
318
 
283
319
  task_name = args[0]
284
320
  task_args = args[1:]
285
321
 
286
- recipe = _get_recipe()
322
+ recipe = _get_recipe(tasks_file)
287
323
  if recipe is None:
288
- console.print("[red]No recipe file found (tasktree.yaml or tt.yaml)[/red]")
324
+ console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
289
325
  raise typer.Exit(1)
290
326
 
291
327
  # Apply global environment override if provided
tasktree/parser.py CHANGED
@@ -94,13 +94,25 @@ class Recipe:
94
94
 
95
95
 
96
96
  def find_recipe_file(start_dir: Path | None = None) -> Path | None:
97
- """Find recipe file (tasktree.yaml or tt.yaml) in current or parent directories.
97
+ """Find recipe file in current or parent directories.
98
+
99
+ Looks for recipe files matching these patterns (in order of preference):
100
+ - tasktree.yaml
101
+ - tasktree.yml
102
+ - tt.yaml
103
+ - *.tasks
104
+
105
+ If multiple recipe files are found in the same directory, raises ValueError
106
+ with instructions to use --tasks option.
98
107
 
99
108
  Args:
100
109
  start_dir: Directory to start searching from (defaults to cwd)
101
110
 
102
111
  Returns:
103
112
  Path to recipe file if found, None otherwise
113
+
114
+ Raises:
115
+ ValueError: If multiple recipe files found in the same directory
104
116
  """
105
117
  if start_dir is None:
106
118
  start_dir = Path.cwd()
@@ -109,10 +121,30 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
109
121
 
110
122
  # Search up the directory tree
111
123
  while True:
112
- for filename in ["tasktree.yaml", "tt.yaml"]:
124
+ candidates = []
125
+
126
+ # Check for exact filenames first
127
+ for filename in ["tasktree.yaml", "tasktree.yml", "tt.yaml"]:
113
128
  recipe_path = current / filename
114
129
  if recipe_path.exists():
115
- return recipe_path
130
+ candidates.append(recipe_path)
131
+
132
+ # Check for *.tasks files
133
+ for tasks_file in current.glob("*.tasks"):
134
+ if tasks_file.is_file():
135
+ candidates.append(tasks_file)
136
+
137
+ if len(candidates) > 1:
138
+ # Multiple recipe files found - ambiguous
139
+ filenames = [c.name for c in candidates]
140
+ raise ValueError(
141
+ f"Multiple recipe files found in {current}:\n"
142
+ f" {', '.join(filenames)}\n\n"
143
+ f"Please specify which file to use with --tasks (-T):\n"
144
+ f" tt --tasks {filenames[0]} <task-name>"
145
+ )
146
+ elif len(candidates) == 1:
147
+ return candidates[0]
116
148
 
117
149
  # Move to parent directory
118
150
  parent = current.parent
@@ -186,11 +218,13 @@ def _parse_file_with_env(
186
218
  return tasks, environments, default_env
187
219
 
188
220
 
189
- def parse_recipe(recipe_path: Path) -> Recipe:
221
+ def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
190
222
  """Parse a recipe file and handle imports recursively.
191
223
 
192
224
  Args:
193
225
  recipe_path: Path to the main recipe file
226
+ project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
227
+ When using --tasks option, this should be the current working directory.
194
228
 
195
229
  Returns:
196
230
  Recipe object with all tasks (including recursively imported tasks)
@@ -204,7 +238,9 @@ def parse_recipe(recipe_path: Path) -> Recipe:
204
238
  if not recipe_path.exists():
205
239
  raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
206
240
 
207
- project_root = recipe_path.parent
241
+ # Default project root to recipe file's parent if not specified
242
+ if project_root is None:
243
+ project_root = recipe_path.parent
208
244
 
209
245
  # Parse main file - it will recursively handle all imports
210
246
  tasks, environments, default_env = _parse_file_with_env(
@@ -263,14 +299,15 @@ def _parse_file(
263
299
  tasks: dict[str, Task] = {}
264
300
  file_dir = file_path.parent
265
301
 
266
- # Default working directory is the file's directory
267
- default_working_dir = str(file_dir.relative_to(project_root)) if file_dir != project_root else "."
302
+ # Default working directory is the project root (where tt is invoked)
303
+ # NOT the directory where the tasks file is located
304
+ default_working_dir = "."
268
305
 
269
306
  # Track local import namespaces for dependency rewriting
270
307
  local_import_namespaces: set[str] = set()
271
308
 
272
309
  # Process nested imports FIRST
273
- imports = data.get("import", [])
310
+ imports = data.get("imports", [])
274
311
  if imports:
275
312
  for import_spec in imports:
276
313
  child_file = import_spec["file"]
@@ -297,15 +334,49 @@ def _parse_file(
297
334
 
298
335
  tasks.update(nested_tasks)
299
336
 
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
337
+ # Validate top-level keys (only imports, environments, and tasks are allowed)
338
+ VALID_TOP_LEVEL_KEYS = {"imports", "environments", "tasks"}
339
+
340
+ # Check if tasks key is missing when there appear to be task definitions at root
341
+ # Do this BEFORE checking for unknown keys, to provide better error message
342
+ if "tasks" not in data and data:
343
+ # Check if there are potential task definitions at root level
344
+ potential_tasks = [
345
+ k for k, v in data.items()
346
+ if isinstance(v, dict) and k not in VALID_TOP_LEVEL_KEYS
347
+ ]
348
+
349
+ if potential_tasks:
350
+ raise ValueError(
351
+ f"Invalid recipe format in {file_path}\n\n"
352
+ f"Task definitions must be under a top-level 'tasks:' key.\n\n"
353
+ f"Found these keys at root level: {', '.join(potential_tasks)}\n\n"
354
+ f"Did you mean:\n\n"
355
+ f"tasks:\n"
356
+ + '\n'.join(f" {k}:" for k in potential_tasks) +
357
+ "\n cmd: ...\n\n"
358
+ f"Valid top-level keys are: {', '.join(sorted(VALID_TOP_LEVEL_KEYS))}"
359
+ )
360
+
361
+ # Now check for other invalid top-level keys (non-dict values)
362
+ invalid_keys = set(data.keys()) - VALID_TOP_LEVEL_KEYS
363
+ if invalid_keys:
364
+ raise ValueError(
365
+ f"Invalid recipe format in {file_path}\n\n"
366
+ f"Unknown top-level keys: {', '.join(sorted(invalid_keys))}\n\n"
367
+ f"Valid top-level keys are:\n"
368
+ f" - imports (for importing task files)\n"
369
+ f" - environments (for shell environment configuration)\n"
370
+ f" - tasks (for task definitions)"
371
+ )
372
+
373
+ # Extract tasks from "tasks" key
374
+ tasks_data = data.get("tasks", {})
375
+ if tasks_data is None:
376
+ tasks_data = {}
303
377
 
304
378
  # Process local tasks
305
379
  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"):
308
- continue
309
380
 
310
381
  if not isinstance(task_data, dict):
311
382
  raise ValueError(f"Task '{task_name}' must be a dictionary")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: A task automation tool with incremental execution
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: click>=8.1.0
@@ -70,33 +70,34 @@ Is this just whining and moaning? Should we just man up and revel in our own abi
70
70
 
71
71
  ... No. That's **a dumb idea**.
72
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.
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
74
 
75
75
  The tasks you need to perform to deliver your project become summarised in an executable file that looks like:
76
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
77
+ tasks:
78
+ build:
79
+ desc: Compile stuff
80
+ outputs: [target/release/bin]
81
+ cmd: cargo build --release
82
+
83
+ package:
84
+ desc: build installers
85
+ deps: [build]
86
+ outputs: [awesome.deb]
87
+ cmd: |
88
+ for bin in target/release/*; do
89
+ if [[ -x "$bin" && ! -d "$bin" ]]; then
90
+ install -Dm755 "$bin" "debian/awesome/usr/bin/$(basename "$bin")"
91
+ fi
92
+ done
93
+
94
+ dpkg-buildpackage -us -uc
95
+
96
+ test:
97
+ desc: Run tests
98
+ deps: [package]
99
+ inputs: [tests/**/*.py]
100
+ cmd: PYTHONPATH=src python3 -m pytest tests/ -v
100
101
  ```
101
102
 
102
103
  If you want to run the tests then:
@@ -107,6 +108,52 @@ Boom! Done. `build` will always run, because there's no sensible way to know wha
107
108
 
108
109
  This is a toy example, but you can image how it plays out on a more complex project.
109
110
 
111
+ ## Migrating from v1.x to v2.0
112
+
113
+ Version 2.0 requires all task definitions to be under a top-level `tasks:` key.
114
+
115
+ ### Quick Migration
116
+
117
+ Wrap your existing tasks in a `tasks:` block:
118
+
119
+ ```yaml
120
+ # Before (v1.x)
121
+ build:
122
+ cmd: cargo build
123
+
124
+ # After (v2.0)
125
+ tasks:
126
+ build:
127
+ cmd: cargo build
128
+ ```
129
+
130
+ ### Why This Change?
131
+
132
+ 1. **Clearer structure**: Explicit separation of tasks from configuration
133
+ 2. **No naming conflicts**: You can now create tasks named "imports" or "environments"
134
+ 3. **Better error messages**: More helpful validation errors
135
+ 4. **Consistency**: All recipe files use the same format
136
+
137
+ ### Error Messages
138
+
139
+ If you forget to update, you'll see a clear error:
140
+
141
+ ```
142
+ Invalid recipe format in tasktree.yaml
143
+
144
+ Task definitions must be under a top-level "tasks:" key.
145
+
146
+ Found these keys at root level: build, test
147
+
148
+ Did you mean:
149
+
150
+ tasks:
151
+ build:
152
+ cmd: ...
153
+ test:
154
+ cmd: ...
155
+ ```
156
+
110
157
  ## Installation
111
158
 
112
159
  ### From PyPI (Recommended)
@@ -141,20 +188,52 @@ cd tasktree
141
188
  pipx install .
142
189
  ```
143
190
 
191
+ ## Editor Support
192
+
193
+ Task Tree includes a [JSON Schema](schema/tasktree-schema.json) that provides autocomplete, validation, and documentation in modern editors.
194
+
195
+ ### VS Code
196
+
197
+ Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then add to your workspace `.vscode/settings.json`:
198
+
199
+ ```json
200
+ {
201
+ "yaml.schemas": {
202
+ "https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
203
+ "tasktree.yaml",
204
+ "tt.yaml"
205
+ ]
206
+ }
207
+ }
208
+ ```
209
+
210
+ Or add a comment at the top of your `tasktree.yaml`:
211
+
212
+ ```yaml
213
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
214
+
215
+ tasks:
216
+ build:
217
+ cmd: cargo build
218
+ ```
219
+
220
+ See [schema/README.md](schema/README.md) for IntelliJ/PyCharm and command-line validation.
221
+
144
222
  ## Quick Start
145
223
 
146
224
  Create a `tasktree.yaml` (or `tt.yaml`) in your project:
147
225
 
148
226
  ```yaml
149
- build:
150
- desc: Compile the application
151
- outputs: [target/release/bin]
152
- cmd: cargo build --release
227
+ tasks:
228
+ build:
229
+ desc: Compile the application
230
+ outputs: [target/release/bin]
231
+ cmd: cargo build --release
153
232
 
154
- test:
155
- desc: Run tests
156
- deps: [build]
157
- cmd: cargo test
233
+ test:
234
+ desc: Run tests
235
+ deps: [build]
236
+ cmd: cargo test
158
237
  ```
159
238
 
160
239
  Run tasks:
@@ -185,15 +264,16 @@ Task Tree only runs tasks when necessary. A task executes if:
185
264
  Tasks automatically inherit inputs from dependencies, eliminating redundant declarations:
186
265
 
187
266
  ```yaml
188
- build:
189
- outputs: [dist/app]
190
- cmd: go build -o dist/app
191
-
192
- package:
193
- deps: [build]
194
- outputs: [dist/app.tar.gz]
195
- cmd: tar czf dist/app.tar.gz dist/app
196
- # Automatically tracks dist/app as an input
267
+ tasks:
268
+ build:
269
+ outputs: [dist/app]
270
+ cmd: go build -o dist/app
271
+
272
+ package:
273
+ deps: [build]
274
+ outputs: [dist/app.tar.gz]
275
+ cmd: tar czf dist/app.tar.gz dist/app
276
+ # Automatically tracks dist/app as an input
197
277
  ```
198
278
 
199
279
  ### Single State File
@@ -205,15 +285,16 @@ All state lives in `.tasktree-state` at your project root. Stale entries are aut
205
285
  ### Basic Structure
206
286
 
207
287
  ```yaml
208
- task-name:
209
- desc: Human-readable description (optional)
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
288
+ tasks:
289
+ task-name:
290
+ desc: Human-readable description (optional)
291
+ deps: [other-task] # Task dependencies
292
+ inputs: [src/**/*.go] # Explicit input files (glob patterns)
293
+ outputs: [dist/binary] # Output files (glob patterns)
294
+ working_dir: subproject/ # Execution directory (default: project root)
295
+ env: bash-strict # Execution environment (optional)
296
+ args: [param1, param2:path=default] # Task parameters
297
+ cmd: go build -o dist/binary # Command to execute
217
298
  ```
218
299
 
219
300
  ### Commands
@@ -221,18 +302,20 @@ task-name:
221
302
  **Single-line commands** are executed directly via the configured shell:
222
303
 
223
304
  ```yaml
224
- build:
225
- cmd: cargo build --release
305
+ tasks:
306
+ build:
307
+ cmd: cargo build --release
226
308
  ```
227
309
 
228
310
  **Multi-line commands** are written to temporary script files for proper execution:
229
311
 
230
312
  ```yaml
231
- deploy:
232
- cmd: |
233
- mkdir -p dist
234
- cp build/* dist/
235
- rsync -av dist/ server:/opt/app/
313
+ tasks:
314
+ deploy:
315
+ cmd: |
316
+ mkdir -p dist
317
+ cp build/* dist/
318
+ rsync -av dist/ server:/opt/app/
236
319
  ```
237
320
 
238
321
  Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
@@ -240,12 +323,13 @@ Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) a
240
323
  Or use folded blocks for long single-line commands:
241
324
 
242
325
  ```yaml
243
- compile:
244
- cmd: >
245
- gcc -o bin/app
246
- src/*.c
247
- -I include
248
- -L lib -lm
326
+ tasks:
327
+ compile:
328
+ cmd: >
329
+ gcc -o bin/app
330
+ src/*.c
331
+ -I include
332
+ -L lib -lm
249
333
  ```
250
334
 
251
335
  ### Execution Environments
@@ -305,12 +389,13 @@ tasks:
305
389
  Tasks can accept arguments with optional defaults:
306
390
 
307
391
  ```yaml
308
- deploy:
309
- args: [environment, region=eu-west-1]
310
- deps: [build]
311
- cmd: |
312
- aws s3 cp dist/app.zip s3://{{environment}}-{{region}}/
313
- aws lambda update-function-code --function-name app-{{environment}}
392
+ tasks:
393
+ deploy:
394
+ args: [environment, region=eu-west-1]
395
+ deps: [build]
396
+ cmd: |
397
+ aws s3 cp dist/app.zip s3://{{environment}}-{{region}}/
398
+ aws lambda update-function-code --function-name app-{{environment}}
314
399
  ```
315
400
 
316
401
  Invoke with: `tt deploy production` or `tt deploy staging us-east-1` or `tt deploy staging region=us-east-1`.
@@ -337,18 +422,19 @@ Split task definitions across multiple files for better organisation:
337
422
 
338
423
  ```yaml
339
424
  # tasktree.yaml
340
- import:
425
+ imports:
341
426
  - file: build/tasks.yml
342
427
  as: build
343
428
  - file: deploy/tasks.yml
344
429
  as: deploy
345
430
 
346
- test:
347
- deps: [build.compile, build.test-compile]
348
- cmd: ./run-tests.sh
431
+ tasks:
432
+ test:
433
+ deps: [build.compile, build.test-compile]
434
+ cmd: ./run-tests.sh
349
435
 
350
- ci:
351
- deps: [build.all, test, deploy.staging]
436
+ ci:
437
+ deps: [build.all, test, deploy.staging]
352
438
  ```
353
439
 
354
440
  Imported tasks are namespaced and can be referenced as dependencies. Each imported file is self-contained—it cannot depend on tasks in the importing file.
@@ -466,41 +552,42 @@ imports:
466
552
  - file: common/docker.yml
467
553
  as: docker
468
554
 
469
- compile:
470
- desc: Build application binaries
471
- outputs: [target/release/app]
472
- cmd: cargo build --release
473
-
474
- test-unit:
475
- desc: Run unit tests
476
- deps: [compile]
477
- cmd: cargo test
478
-
479
- package:
480
- desc: Create distribution archive
481
- deps: [compile]
482
- outputs: [dist/app-{{version}}.tar.gz]
483
- args: [version]
484
- cmd: |
485
- mkdir -p dist
486
- tar czf dist/app-{{version}}.tar.gz \
487
- target/release/app \
488
- config/ \
489
- migrations/
490
-
491
- deploy:
492
- desc: Deploy to environment
493
- deps: [package, docker.build-runtime]
494
- args: [environment, version]
495
- cmd: |
496
- scp dist/app-{{version}}.tar.gz {{environment}}:/opt/
497
- ssh {{environment}} /opt/deploy.sh {{version}}
498
-
499
- integration-test:
500
- desc: Run integration tests against deployed environment
501
- deps: [deploy]
502
- args: [environment, version]
503
- cmd: pytest tests/integration/ --env={{environment}}
555
+ tasks:
556
+ compile:
557
+ desc: Build application binaries
558
+ outputs: [target/release/app]
559
+ cmd: cargo build --release
560
+
561
+ test-unit:
562
+ desc: Run unit tests
563
+ deps: [compile]
564
+ cmd: cargo test
565
+
566
+ package:
567
+ desc: Create distribution archive
568
+ deps: [compile]
569
+ outputs: [dist/app-{{version}}.tar.gz]
570
+ args: [version]
571
+ cmd: |
572
+ mkdir -p dist
573
+ tar czf dist/app-{{version}}.tar.gz \
574
+ target/release/app \
575
+ config/ \
576
+ migrations/
577
+
578
+ deploy:
579
+ desc: Deploy to environment
580
+ deps: [package, docker.build-runtime]
581
+ args: [environment, version]
582
+ cmd: |
583
+ scp dist/app-{{version}}.tar.gz {{environment}}:/opt/
584
+ ssh {{environment}} /opt/deploy.sh {{version}}
585
+
586
+ integration-test:
587
+ desc: Run integration tests against deployed environment
588
+ deps: [deploy]
589
+ args: [environment, version]
590
+ cmd: pytest tests/integration/ --env={{environment}}
504
591
  ```
505
592
 
506
593
  Run the full pipeline:
@@ -1,13 +1,13 @@
1
1
  tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
2
- tasktree/cli.py,sha256=2Pm0pxWdG4RaQbVMijaMUIrm3v0b9J98CUsUp7cDFvI,13236
2
+ tasktree/cli.py,sha256=0xusNitT1AtLgR3guUsupnHSXJ0_C749Dx7dfYCENJA,15233
3
3
  tasktree/executor.py,sha256=_E37tShHuiOj0Mvx2GbS9y3GIozC3hpzAVhAjbvYJqg,18638
4
4
  tasktree/graph.py,sha256=9ngfg93y7EkOIN_lUQa0u-JhnwiMN1UdQQvIFw8RYCE,4181
5
5
  tasktree/hasher.py,sha256=puJey9wF_p37k_xqjhYr_6ICsbAfrTBWHec6MqKV4BU,814
6
- tasktree/parser.py,sha256=apLfN3_YVa7lQIy0rcHFwo931Pg-8mKG1044AQE6fLQ,12690
6
+ tasktree/parser.py,sha256=SzWn-V4KMgjxNZrN0ERApb5dd39LPJTfkA2Ih2nYWcs,15580
7
7
  tasktree/state.py,sha256=rxKtS3SbsPtAuraHbN807RGWfoYYkQ3pe8CxUstwo2k,3535
8
8
  tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
9
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,,
10
+ tasktree-0.0.6.dist-info/METADATA,sha256=GQZHcFVOIXfWmgPbb9pAt7zB0iH22hyGToc0YEwUv34,18287
11
+ tasktree-0.0.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ tasktree-0.0.6.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
13
+ tasktree-0.0.6.dist-info/RECORD,,