tasktree 0.0.5__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)
@@ -169,6 +178,7 @@ def main(
169
178
  list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
170
179
  show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
171
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.)"),
172
182
  init: Optional[bool] = typer.Option(
173
183
  None, "--init", "-i", help="Create a blank tasktree.yaml"
174
184
  ),
@@ -208,15 +218,15 @@ def main(
208
218
  """
209
219
 
210
220
  if list_opt:
211
- _list_tasks()
221
+ _list_tasks(tasks_file)
212
222
  raise typer.Exit()
213
223
 
214
224
  if show:
215
- _show_task(show)
225
+ _show_task(show, tasks_file)
216
226
  raise typer.Exit()
217
227
 
218
228
  if tree:
219
- _show_tree(tree)
229
+ _show_tree(tree, tasks_file)
220
230
  raise typer.Exit()
221
231
 
222
232
  if init:
@@ -224,17 +234,17 @@ def main(
224
234
  raise typer.Exit()
225
235
 
226
236
  if clean or clean_state or reset:
227
- _clean_state()
237
+ _clean_state(tasks_file)
228
238
  raise typer.Exit()
229
239
 
230
240
  if task_args:
231
241
  # --only implies --force
232
242
  force_execution = force or only or False
233
- _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)
234
244
  else:
235
- recipe = _get_recipe()
245
+ recipe = _get_recipe(tasks_file)
236
246
  if recipe is None:
237
- 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]")
238
248
  console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
239
249
  raise typer.Exit(1)
240
250
 
@@ -245,13 +255,19 @@ def main(
245
255
  console.print("Use [cyan]tt <task-name>[/cyan] to run a task")
246
256
 
247
257
 
248
- def _clean_state() -> None:
258
+ def _clean_state(tasks_file: Optional[str] = None) -> None:
249
259
  """Remove the .tasktree-state file to reset task execution state."""
250
- recipe_path = find_recipe_file()
251
- if recipe_path is None:
252
- console.print("[yellow]No recipe file found[/yellow]")
253
- console.print("State file location depends on recipe file location")
254
- 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)
255
271
 
256
272
  project_root = recipe_path.parent
257
273
  state_path = project_root / ".tasktree-state"
@@ -264,29 +280,48 @@ def _clean_state() -> None:
264
280
  console.print(f"[yellow]No state file found at {state_path}[/yellow]")
265
281
 
266
282
 
267
- def _get_recipe() -> Optional[Recipe]:
268
- """Get parsed recipe or None if not found."""
269
- recipe_path = find_recipe_file()
270
- if recipe_path is None:
271
- 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
272
307
 
273
308
  try:
274
- return parse_recipe(recipe_path)
309
+ return parse_recipe(recipe_path, project_root)
275
310
  except Exception as e:
276
311
  console.print(f"[red]Error parsing recipe: {e}[/red]")
277
312
  raise typer.Exit(1)
278
313
 
279
314
 
280
- 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:
281
316
  if not args:
282
317
  return
283
318
 
284
319
  task_name = args[0]
285
320
  task_args = args[1:]
286
321
 
287
- recipe = _get_recipe()
322
+ recipe = _get_recipe(tasks_file)
288
323
  if recipe is None:
289
- 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]")
290
325
  raise typer.Exit(1)
291
326
 
292
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,8 +299,9 @@ 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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree
3
- Version: 0.0.5
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
@@ -188,6 +188,37 @@ cd tasktree
188
188
  pipx install .
189
189
  ```
190
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
+
191
222
  ## Quick Start
192
223
 
193
224
  Create a `tasktree.yaml` (or `tt.yaml`) in your project:
@@ -1,13 +1,13 @@
1
1
  tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
2
- tasktree/cli.py,sha256=jeLFmyFjTeF6JXDPLI46H5Xao9br68tPWL7D6LbC7u0,13272
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=5WXoqN2cUtO9icIPDSvK5889XinBcAQMBCyNoJ2ZO6w,14152
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.5.dist-info/METADATA,sha256=ncZRu7zCKZRd5YGG5fAWAL1TtH1mXbtnTqAne3RHdeA,17456
11
- tasktree-0.0.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
- tasktree-0.0.5.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
13
- tasktree-0.0.5.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,,