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/__init__.py +4 -0
- tasktree/cli.py +29 -5
- tasktree/executor.py +41 -3
- tasktree/graph.py +177 -1
- tasktree/hasher.py +32 -3
- tasktree/parser.py +196 -18
- tasktree/substitution.py +156 -1
- {tasktree-0.0.18.dist-info → tasktree-0.0.20.dist-info}/METADATA +452 -1
- tasktree-0.0.20.dist-info/RECORD +14 -0
- tasktree-0.0.18.dist-info/RECORD +0 -14
- {tasktree-0.0.18.dist-info → tasktree-0.0.20.dist-info}/WHEEL +0 -0
- {tasktree-0.0.18.dist-info → tasktree-0.0.20.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
1377
|
-
|
|
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)
|
|
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)
|