tasktree 0.0.18__py3-none-any.whl → 0.0.20__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/parser.py CHANGED
@@ -57,15 +57,24 @@ class Task:
57
57
  cmd: str
58
58
  desc: str = ""
59
59
  deps: list[str | dict[str, Any]] = field(default_factory=list) # Can be strings or dicts with args
60
- inputs: list[str] = field(default_factory=list)
61
- outputs: list[str] = field(default_factory=list)
60
+ inputs: list[str | dict[str, str]] = field(default_factory=list) # Can be strings or dicts with named inputs
61
+ outputs: list[str | dict[str, str]] = field(default_factory=list) # Can be strings or dicts with named outputs
62
62
  working_dir: str = ""
63
63
  args: list[str | dict[str, Any]] = field(default_factory=list) # Can be strings or dicts (each dict has single key: arg name)
64
64
  source_file: str = "" # Track which file defined this task
65
65
  env: str = "" # Environment name to use for execution
66
+ private: bool = False # If True, task is hidden from --list output
67
+
68
+ # Internal fields for efficient output lookup (built in __post_init__)
69
+ _output_map: dict[str, str] = field(init=False, default_factory=dict, repr=False) # name → path mapping
70
+ _anonymous_outputs: list[str] = field(init=False, default_factory=list, repr=False) # unnamed outputs
71
+
72
+ # Internal fields for efficient input lookup (built in __post_init__)
73
+ _input_map: dict[str, str] = field(init=False, default_factory=dict, repr=False) # name → path mapping
74
+ _anonymous_inputs: list[str] = field(init=False, default_factory=list, repr=False) # unnamed inputs
66
75
 
67
76
  def __post_init__(self):
68
- """Ensure lists are always lists."""
77
+ """Ensure lists are always lists and build output maps."""
69
78
  if isinstance(self.deps, str):
70
79
  self.deps = [self.deps]
71
80
  if isinstance(self.inputs, str):
@@ -88,6 +97,84 @@ class Task:
88
97
  f"Arguments must be defined as a list, not a dictionary."
89
98
  )
90
99
 
100
+ # Build output maps for efficient lookup
101
+ self._output_map = {}
102
+ self._anonymous_outputs = []
103
+
104
+ for idx, output in enumerate(self.outputs):
105
+ if isinstance(output, dict):
106
+ # Named output: validate and store
107
+ if len(output) != 1:
108
+ raise ValueError(
109
+ f"Task '{self.name}': Named output at index {idx} must have exactly one key-value pair, got {len(output)}: {output}"
110
+ )
111
+
112
+ name, path = next(iter(output.items()))
113
+
114
+ if not isinstance(path, str):
115
+ raise ValueError(
116
+ f"Task '{self.name}': Named output '{name}' must have a string path, got {type(path).__name__}: {path}"
117
+ )
118
+
119
+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
120
+ raise ValueError(
121
+ f"Task '{self.name}': Named output '{name}' must be a valid identifier "
122
+ f"(letters, numbers, underscores, cannot start with number)"
123
+ )
124
+
125
+ if name in self._output_map:
126
+ raise ValueError(
127
+ f"Task '{self.name}': Duplicate output name '{name}' at index {idx}"
128
+ )
129
+
130
+ self._output_map[name] = path
131
+ elif isinstance(output, str):
132
+ # Anonymous output: just store
133
+ self._anonymous_outputs.append(output)
134
+ else:
135
+ raise ValueError(
136
+ f"Task '{self.name}': Output at index {idx} must be a string or dict, got {type(output).__name__}: {output}"
137
+ )
138
+
139
+ # Build input maps for efficient lookup
140
+ self._input_map = {}
141
+ self._anonymous_inputs = []
142
+
143
+ for idx, input_item in enumerate(self.inputs):
144
+ if isinstance(input_item, dict):
145
+ # Named input: validate and store
146
+ if len(input_item) != 1:
147
+ raise ValueError(
148
+ f"Task '{self.name}': Named input at index {idx} must have exactly one key-value pair, got {len(input_item)}: {input_item}"
149
+ )
150
+
151
+ name, path = next(iter(input_item.items()))
152
+
153
+ if not isinstance(path, str):
154
+ raise ValueError(
155
+ f"Task '{self.name}': Named input '{name}' must have a string path, got {type(path).__name__}: {path}"
156
+ )
157
+
158
+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
159
+ raise ValueError(
160
+ f"Task '{self.name}': Named input '{name}' must be a valid identifier "
161
+ f"(letters, numbers, underscores, cannot start with number)"
162
+ )
163
+
164
+ if name in self._input_map:
165
+ raise ValueError(
166
+ f"Task '{self.name}': Duplicate input name '{name}' at index {idx}"
167
+ )
168
+
169
+ self._input_map[name] = path
170
+ elif isinstance(input_item, str):
171
+ # Anonymous input: just store
172
+ self._anonymous_inputs.append(input_item)
173
+ else:
174
+ raise ValueError(
175
+ f"Task '{self.name}': Input at index {idx} must be a string or dict, got {type(input_item).__name__}: {input_item}"
176
+ )
177
+
91
178
 
92
179
  @dataclass
93
180
  class DependencySpec:
@@ -265,10 +352,71 @@ class Recipe:
265
352
  task.cmd = substitute_variables(task.cmd, self.evaluated_variables)
266
353
  task.desc = substitute_variables(task.desc, self.evaluated_variables)
267
354
  task.working_dir = substitute_variables(task.working_dir, self.evaluated_variables)
268
- task.inputs = [substitute_variables(inp, self.evaluated_variables) for inp in task.inputs]
269
- task.outputs = [substitute_variables(out, self.evaluated_variables) for out in task.outputs]
270
- # Substitute in argument default values (in arg spec strings)
271
- task.args = [substitute_variables(arg, self.evaluated_variables) for arg in task.args]
355
+
356
+ # Substitute variables in inputs (handle both string and dict inputs)
357
+ resolved_inputs = []
358
+ for inp in task.inputs:
359
+ if isinstance(inp, str):
360
+ resolved_inputs.append(substitute_variables(inp, self.evaluated_variables))
361
+ elif isinstance(inp, dict):
362
+ # Named input: substitute the path value
363
+ resolved_dict = {}
364
+ for name, path in inp.items():
365
+ resolved_dict[name] = substitute_variables(path, self.evaluated_variables)
366
+ resolved_inputs.append(resolved_dict)
367
+ else:
368
+ resolved_inputs.append(inp)
369
+ task.inputs = resolved_inputs
370
+
371
+ # Substitute variables in outputs (handle both string and dict outputs)
372
+ resolved_outputs = []
373
+ for out in task.outputs:
374
+ if isinstance(out, str):
375
+ resolved_outputs.append(substitute_variables(out, self.evaluated_variables))
376
+ elif isinstance(out, dict):
377
+ # Named output: substitute the path value
378
+ resolved_dict = {}
379
+ for name, path in out.items():
380
+ resolved_dict[name] = substitute_variables(path, self.evaluated_variables)
381
+ resolved_outputs.append(resolved_dict)
382
+ else:
383
+ resolved_outputs.append(out)
384
+ task.outputs = resolved_outputs
385
+
386
+ # Rebuild output maps after variable substitution
387
+ task.__post_init__()
388
+
389
+ # Substitute in argument default values (handle both string and dict args)
390
+ resolved_args = []
391
+ for arg in task.args:
392
+ if isinstance(arg, str):
393
+ resolved_args.append(substitute_variables(arg, self.evaluated_variables))
394
+ elif isinstance(arg, dict):
395
+ # Dict arg: substitute in nested values (like default values)
396
+ resolved_dict = {}
397
+ for arg_name, arg_spec in arg.items():
398
+ if isinstance(arg_spec, dict):
399
+ # Substitute in the nested dict values (e.g., default, help, choices)
400
+ resolved_spec = {}
401
+ for key, value in arg_spec.items():
402
+ if isinstance(value, str):
403
+ resolved_spec[key] = substitute_variables(value, self.evaluated_variables)
404
+ elif isinstance(value, list):
405
+ # Handle lists like 'choices'
406
+ resolved_spec[key] = [
407
+ substitute_variables(v, self.evaluated_variables) if isinstance(v, str) else v
408
+ for v in value
409
+ ]
410
+ else:
411
+ resolved_spec[key] = value
412
+ resolved_dict[arg_name] = resolved_spec
413
+ else:
414
+ # Simple value
415
+ resolved_dict[arg_name] = substitute_variables(arg_spec, self.evaluated_variables) if isinstance(arg_spec, str) else arg_spec
416
+ resolved_args.append(resolved_dict)
417
+ else:
418
+ resolved_args.append(arg)
419
+ task.args = resolved_args
272
420
 
273
421
  # Substitute evaluated variables into all environments
274
422
  for env in self.environments.values():
@@ -335,19 +483,15 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
335
483
  while True:
336
484
  candidates = []
337
485
 
338
- # Check for exact filenames first
486
+ # Check for exact filenames first (these are preferred)
339
487
  for filename in ["tasktree.yaml", "tasktree.yml", "tt.yaml"]:
340
488
  recipe_path = current / filename
341
489
  if recipe_path.exists():
342
490
  candidates.append(recipe_path)
343
491
 
344
- # Check for *.tasks files
345
- for tasks_file in current.glob("*.tasks"):
346
- if tasks_file.is_file():
347
- candidates.append(tasks_file)
348
-
492
+ # If we found standard recipe files, use the first one
349
493
  if len(candidates) > 1:
350
- # Multiple recipe files found - ambiguous
494
+ # Multiple standard recipe files found - ambiguous
351
495
  filenames = [c.name for c in candidates]
352
496
  raise ValueError(
353
497
  f"Multiple recipe files found in {current}:\n"
@@ -358,6 +502,25 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
358
502
  elif len(candidates) == 1:
359
503
  return candidates[0]
360
504
 
505
+ # Only check for *.tasks files if no standard recipe files found
506
+ # (*.tasks files are typically imports, not main recipes)
507
+ tasks_files = []
508
+ for tasks_file in current.glob("*.tasks"):
509
+ if tasks_file.is_file():
510
+ tasks_files.append(tasks_file)
511
+
512
+ if len(tasks_files) > 1:
513
+ # Multiple *.tasks files found - ambiguous
514
+ filenames = [t.name for t in tasks_files]
515
+ raise ValueError(
516
+ f"Multiple recipe files found in {current}:\n"
517
+ f" {', '.join(filenames)}\n\n"
518
+ f"Please specify which file to use with --tasks (-T):\n"
519
+ f" tt --tasks {filenames[0]} <task-name>"
520
+ )
521
+ elif len(tasks_files) == 1:
522
+ return tasks_files[0]
523
+
361
524
  # Move to parent directory
362
525
  parent = current.parent
363
526
  if parent == current:
@@ -1367,14 +1530,28 @@ def collect_reachable_variables(
1367
1530
  # Search in inputs
1368
1531
  if task.inputs:
1369
1532
  for input_pattern in task.inputs:
1370
- for match in var_pattern.finditer(input_pattern):
1371
- variables.add(match.group(1))
1533
+ if isinstance(input_pattern, str):
1534
+ for match in var_pattern.finditer(input_pattern):
1535
+ variables.add(match.group(1))
1536
+ elif isinstance(input_pattern, dict):
1537
+ # Named input - check the path value
1538
+ for input_path in input_pattern.values():
1539
+ if isinstance(input_path, str):
1540
+ for match in var_pattern.finditer(input_path):
1541
+ variables.add(match.group(1))
1372
1542
 
1373
1543
  # Search in outputs
1374
1544
  if task.outputs:
1375
1545
  for output_pattern in task.outputs:
1376
- for match in var_pattern.finditer(output_pattern):
1377
- variables.add(match.group(1))
1546
+ if isinstance(output_pattern, str):
1547
+ for match in var_pattern.finditer(output_pattern):
1548
+ variables.add(match.group(1))
1549
+ elif isinstance(output_pattern, dict):
1550
+ # Named output - check the path value
1551
+ for output_path in output_pattern.values():
1552
+ if isinstance(output_path, str):
1553
+ for match in var_pattern.finditer(output_path):
1554
+ variables.add(match.group(1))
1378
1555
 
1379
1556
  # Search in argument defaults
1380
1557
  if task.args:
@@ -1700,6 +1877,7 @@ def _parse_file(
1700
1877
  args=task_data.get("args", []),
1701
1878
  source_file=str(file_path),
1702
1879
  env=task_data.get("env", ""),
1880
+ private=task_data.get("private", False),
1703
1881
  )
1704
1882
 
1705
1883
  # Check for case-sensitive argument collisions
tasktree/substitution.py CHANGED
@@ -12,7 +12,19 @@ from typing import Any
12
12
  # Pattern matches: {{ prefix.name }} with optional whitespace
13
13
  # Groups: (1) prefix (var|arg|env|tt), (2) name (identifier)
14
14
  PLACEHOLDER_PATTERN = re.compile(
15
- r'\{\{\s*(var|arg|env|tt)\s*\.\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
15
+ r'\{\{\s*(var|arg|env|tt)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
16
+ )
17
+
18
+ # Pattern matches: {{ dep.task_name.outputs.output_name }} with optional whitespace
19
+ # Groups: (1) task_name (can include dots for namespacing), (2) output_name (identifier)
20
+ DEP_OUTPUT_PATTERN = re.compile(
21
+ r'\{\{\s*dep\.([a-zA-Z_][a-zA-Z0-9_.-]*)\.outputs\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
22
+ )
23
+
24
+ # Pattern matches: {{ self.(inputs|outputs).name }} with optional whitespace
25
+ # Groups: (1) field (inputs|outputs), (2) name (identifier)
26
+ SELF_REFERENCE_PATTERN = re.compile(
27
+ r'\{\{\s*self\.(inputs|outputs)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
16
28
  )
17
29
 
18
30
 
@@ -290,3 +302,146 @@ def substitute_all(text: str, variables: dict[str, str], args: dict[str, Any]) -
290
302
  text = substitute_arguments(text, args)
291
303
  text = substitute_environment(text)
292
304
  return text
305
+
306
+
307
+ def substitute_dependency_outputs(
308
+ text: str,
309
+ current_task_name: str,
310
+ current_task_deps: list[str],
311
+ resolved_tasks: dict[str, Any],
312
+ ) -> str:
313
+ """Substitute {{ dep.<task>.outputs.<name> }} placeholders with dependency output paths.
314
+
315
+ This function resolves references to named outputs from dependency tasks.
316
+ It validates that:
317
+ - The referenced task exists in the resolved_tasks dict
318
+ - The current task lists the referenced task as a dependency
319
+ - The referenced output name exists in the dependency task
320
+
321
+ Args:
322
+ text: Text containing {{ dep.*.outputs.* }} placeholders
323
+ current_task_name: Name of task being resolved (for error messages)
324
+ current_task_deps: List of dependency task names for the current task
325
+ resolved_tasks: Dictionary mapping task names to Task objects (already resolved)
326
+
327
+ Returns:
328
+ Text with all {{ dep.*.outputs.* }} placeholders replaced with output paths
329
+
330
+ Raises:
331
+ ValueError: If referenced task doesn't exist, isn't a dependency,
332
+ or doesn't have the named output
333
+
334
+ Example:
335
+ >>> # Assuming build task has output { bundle: "dist/app.js" }
336
+ >>> substitute_dependency_outputs(
337
+ ... "Deploy {{ dep.build.outputs.bundle }}",
338
+ ... "deploy",
339
+ ... ["build"],
340
+ ... {"build": build_task}
341
+ ... )
342
+ 'Deploy dist/app.js'
343
+ """
344
+ def replacer(match: re.Match) -> str:
345
+ dep_task_name = match.group(1)
346
+ output_name = match.group(2)
347
+
348
+ # Check if dependency task exists in resolved tasks
349
+ if dep_task_name not in resolved_tasks:
350
+ raise ValueError(
351
+ f"Task '{current_task_name}' references output from unknown task '{dep_task_name}'.\n"
352
+ f"Check the task name in {{{{ dep.{dep_task_name}.outputs.{output_name} }}}}"
353
+ )
354
+
355
+ # Check if current task depends on referenced task
356
+ if dep_task_name not in current_task_deps:
357
+ raise ValueError(
358
+ f"Task '{current_task_name}' references output from '{dep_task_name}' "
359
+ f"but does not list it as a dependency.\n"
360
+ f"Add '{dep_task_name}' to the deps list:\n"
361
+ f" deps: [{', '.join(current_task_deps + [dep_task_name])}]"
362
+ )
363
+
364
+ # Get the dependency task
365
+ dep_task = resolved_tasks[dep_task_name]
366
+
367
+ # Look up the named output
368
+ if output_name not in dep_task._output_map:
369
+ available = list(dep_task._output_map.keys())
370
+ available_msg = ", ".join(available) if available else "(none - all outputs are anonymous)"
371
+ raise ValueError(
372
+ f"Task '{current_task_name}' references output '{output_name}' "
373
+ f"from task '{dep_task_name}', but '{dep_task_name}' has no output named '{output_name}'.\n"
374
+ f"Available named outputs in '{dep_task_name}': {available_msg}\n"
375
+ f"Hint: Define named outputs like: outputs: [{{ {output_name}: 'path/to/file' }}]"
376
+ )
377
+
378
+ return dep_task._output_map[output_name]
379
+
380
+ return DEP_OUTPUT_PATTERN.sub(replacer, text)
381
+
382
+
383
+ def substitute_self_references(
384
+ text: str,
385
+ task_name: str,
386
+ input_map: dict[str, str],
387
+ output_map: dict[str, str],
388
+ ) -> str:
389
+ """Substitute {{ self.inputs.name }} and {{ self.outputs.name }} placeholders.
390
+
391
+ This function resolves references to the task's own named inputs and outputs.
392
+ Only named entries are accessible; anonymous inputs/outputs cannot be referenced.
393
+ The substitution is literal string replacement - no glob expansion or path resolution.
394
+
395
+ Args:
396
+ text: Text containing {{ self.* }} placeholders
397
+ task_name: Name of current task (for error messages)
398
+ input_map: Dictionary mapping input names to path strings
399
+ output_map: Dictionary mapping output names to path strings
400
+
401
+ Returns:
402
+ Text with all {{ self.* }} placeholders replaced with literal path strings
403
+
404
+ Raises:
405
+ ValueError: If referenced name doesn't exist in input_map or output_map
406
+
407
+ Example:
408
+ >>> input_map = {"src": "*.txt"}
409
+ >>> output_map = {"dest": "out/result.txt"}
410
+ >>> substitute_self_references(
411
+ ... "cp {{ self.inputs.src }} {{ self.outputs.dest }}",
412
+ ... "copy",
413
+ ... input_map,
414
+ ... output_map
415
+ ... )
416
+ 'cp *.txt out/result.txt'
417
+ """
418
+ def replacer(match: re.Match) -> str:
419
+ field = match.group(1) # "inputs" or "outputs"
420
+ name = match.group(2)
421
+
422
+ # Select appropriate map
423
+ if field == "inputs":
424
+ name_map = input_map
425
+ field_display = "input"
426
+ else: # field == "outputs"
427
+ name_map = output_map
428
+ field_display = "output"
429
+
430
+ # Check if name exists in map
431
+ if name not in name_map:
432
+ available = list(name_map.keys())
433
+ if available:
434
+ available_msg = ", ".join(available)
435
+ else:
436
+ available_msg = f"(none - all {field} are anonymous)"
437
+
438
+ raise ValueError(
439
+ f"Task '{task_name}' references {field_display} '{name}' "
440
+ f"but has no {field_display} named '{name}'.\n"
441
+ f"Available named {field}: {available_msg}\n"
442
+ f"Hint: Define named {field} like: {field}: [{{ {name}: 'path/to/file' }}]"
443
+ )
444
+
445
+ return name_map[name]
446
+
447
+ return SELF_REFERENCE_PATTERN.sub(replacer, text)