tasktree 0.0.20__py3-none-any.whl → 0.0.22__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
@@ -1,4 +1,7 @@
1
- """Parse recipe YAML files and handle imports."""
1
+ """
2
+ Parse recipe YAML files and handle imports.
3
+ @athena: ec0087b16df9
4
+ """
2
5
 
3
6
  from __future__ import annotations
4
7
 
@@ -16,22 +19,30 @@ from tasktree.types import get_click_type
16
19
 
17
20
 
18
21
  class CircularImportError(Exception):
19
- """Raised when a circular import is detected."""
22
+ """
23
+ Raised when a circular import is detected.
24
+ @athena: 935d53bc7d05
25
+ """
26
+
20
27
  pass
21
28
 
22
29
 
23
30
  @dataclass
24
31
  class Environment:
25
- """Represents an execution environment configuration.
32
+ """
33
+ Represents an execution environment configuration.
26
34
 
27
35
  Can be either a shell environment or a Docker environment:
28
36
  - Shell environment: has 'shell' field, executes directly on host
29
37
  - Docker environment: has 'dockerfile' field, executes in container
38
+ @athena: cfe3f8754968
30
39
  """
31
40
 
32
41
  name: str
33
42
  shell: str = "" # Path to shell (required for shell envs, optional for Docker)
34
- args: list[str] | dict[str, str] = field(default_factory=list) # Shell args (list) or Docker build args (dict)
43
+ args: list[str] | dict[str, str] = field(
44
+ default_factory=list
45
+ ) # Shell args (list) or Docker build args (dict)
35
46
  preamble: str = ""
36
47
  # Docker-specific fields (presence of dockerfile indicates Docker environment)
37
48
  dockerfile: str = "" # Path to Dockerfile
@@ -40,41 +51,76 @@ class Environment:
40
51
  ports: list[str] = field(default_factory=list) # Port mappings
41
52
  env_vars: dict[str, str] = field(default_factory=dict) # Environment variables
42
53
  working_dir: str = "" # Working directory (container or host)
43
- extra_args: List[str] = field(default_factory=list) # Any extra arguments to pass to docker
54
+ extra_args: List[str] = field(
55
+ default_factory=list
56
+ ) # Any extra arguments to pass to docker
44
57
  run_as_root: bool = False # If True, skip user mapping (run as root in container)
45
58
 
46
59
  def __post_init__(self):
47
- """Ensure args is in the correct format."""
60
+ """
61
+ Ensure args is in the correct format.
62
+ @athena: a4292f3f4150
63
+ """
48
64
  if isinstance(self.args, str):
49
65
  self.args = [self.args]
50
66
 
51
67
 
52
68
  @dataclass
53
69
  class Task:
54
- """Represents a task definition."""
70
+ """
71
+ Represents a task definition.
72
+ @athena: f516b5ae61c5
73
+ """
55
74
 
56
75
  name: str
57
76
  cmd: str
58
77
  desc: str = ""
59
- deps: list[str | dict[str, Any]] = field(default_factory=list) # Can be strings or dicts with args
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
78
+ deps: list[str | dict[str, Any]] = field(
79
+ default_factory=list
80
+ ) # Can be strings or dicts with args
81
+ inputs: list[str | dict[str, str]] = field(
82
+ default_factory=list
83
+ ) # Can be strings or dicts with named inputs
84
+ outputs: list[str | dict[str, str]] = field(
85
+ default_factory=list
86
+ ) # Can be strings or dicts with named outputs
62
87
  working_dir: str = ""
63
- args: list[str | dict[str, Any]] = field(default_factory=list) # Can be strings or dicts (each dict has single key: arg name)
88
+ args: list[str | dict[str, Any]] = field(
89
+ default_factory=list
90
+ ) # Can be strings or dicts (each dict has single key: arg name)
64
91
  source_file: str = "" # Track which file defined this task
65
92
  env: str = "" # Environment name to use for execution
66
93
  private: bool = False # If True, task is hidden from --list output
67
94
 
68
95
  # 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
96
+ _output_map: dict[str, str] = field(
97
+ init=False, default_factory=dict, repr=False
98
+ ) # name → path mapping
99
+ _anonymous_outputs: list[str] = field(
100
+ init=False, default_factory=list, repr=False
101
+ ) # unnamed outputs
71
102
 
72
103
  # 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
104
+ _input_map: dict[str, str] = field(
105
+ init=False, default_factory=dict, repr=False
106
+ ) # name → path mapping
107
+ _anonymous_inputs: list[str] = field(
108
+ init=False, default_factory=list, repr=False
109
+ ) # unnamed inputs
110
+
111
+ # Internal fields for positional input/output access (built in __post_init__)
112
+ _indexed_inputs: list[str] = field(
113
+ init=False, default_factory=list, repr=False
114
+ ) # all inputs in YAML order
115
+ _indexed_outputs: list[str] = field(
116
+ init=False, default_factory=list, repr=False
117
+ ) # all outputs in YAML order
75
118
 
76
119
  def __post_init__(self):
77
- """Ensure lists are always lists and build output maps."""
120
+ """
121
+ Ensure lists are always lists and build input/output maps and indexed lists.
122
+ @athena: a48b1eba81cd
123
+ """
78
124
  if isinstance(self.deps, str):
79
125
  self.deps = [self.deps]
80
126
  if isinstance(self.inputs, str):
@@ -100,6 +146,7 @@ class Task:
100
146
  # Build output maps for efficient lookup
101
147
  self._output_map = {}
102
148
  self._anonymous_outputs = []
149
+ self._indexed_outputs = []
103
150
 
104
151
  for idx, output in enumerate(self.outputs):
105
152
  if isinstance(output, dict):
@@ -116,7 +163,7 @@ class Task:
116
163
  f"Task '{self.name}': Named output '{name}' must have a string path, got {type(path).__name__}: {path}"
117
164
  )
118
165
 
119
- if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
166
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
120
167
  raise ValueError(
121
168
  f"Task '{self.name}': Named output '{name}' must be a valid identifier "
122
169
  f"(letters, numbers, underscores, cannot start with number)"
@@ -128,9 +175,11 @@ class Task:
128
175
  )
129
176
 
130
177
  self._output_map[name] = path
178
+ self._indexed_outputs.append(path)
131
179
  elif isinstance(output, str):
132
180
  # Anonymous output: just store
133
181
  self._anonymous_outputs.append(output)
182
+ self._indexed_outputs.append(output)
134
183
  else:
135
184
  raise ValueError(
136
185
  f"Task '{self.name}': Output at index {idx} must be a string or dict, got {type(output).__name__}: {output}"
@@ -139,6 +188,7 @@ class Task:
139
188
  # Build input maps for efficient lookup
140
189
  self._input_map = {}
141
190
  self._anonymous_inputs = []
191
+ self._indexed_inputs = []
142
192
 
143
193
  for idx, input_item in enumerate(self.inputs):
144
194
  if isinstance(input_item, dict):
@@ -155,7 +205,7 @@ class Task:
155
205
  f"Task '{self.name}': Named input '{name}' must have a string path, got {type(path).__name__}: {path}"
156
206
  )
157
207
 
158
- if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
208
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
159
209
  raise ValueError(
160
210
  f"Task '{self.name}': Named input '{name}' must be a valid identifier "
161
211
  f"(letters, numbers, underscores, cannot start with number)"
@@ -167,9 +217,11 @@ class Task:
167
217
  )
168
218
 
169
219
  self._input_map[name] = path
220
+ self._indexed_inputs.append(path)
170
221
  elif isinstance(input_item, str):
171
222
  # Anonymous input: just store
172
223
  self._anonymous_inputs.append(input_item)
224
+ self._indexed_inputs.append(input_item)
173
225
  else:
174
226
  raise ValueError(
175
227
  f"Task '{self.name}': Input at index {idx} must be a string or dict, got {type(input_item).__name__}: {input_item}"
@@ -178,23 +230,29 @@ class Task:
178
230
 
179
231
  @dataclass
180
232
  class DependencySpec:
181
- """Parsed dependency specification with potential template placeholders.
233
+ """
234
+ Parsed dependency specification with potential template placeholders.
182
235
 
183
236
  This represents a dependency as defined in the recipe file, before template
184
237
  substitution. Argument values may contain {{ arg.* }} templates that will be
185
238
  substituted with parent task's argument values during graph construction.
186
239
 
187
240
  Attributes:
188
- task_name: Name of the dependency task
189
- arg_templates: Dictionary mapping argument names to string templates
190
- (None if no args specified). All values are strings, even
191
- for numeric types, to preserve template placeholders.
241
+ task_name: Name of the dependency task
242
+ arg_templates: Dictionary mapping argument names to string templates
243
+ (None if no args specified). All values are strings, even
244
+ for numeric types, to preserve template placeholders.
245
+ @athena: 7b2f8a15d312
192
246
  """
247
+
193
248
  task_name: str
194
249
  arg_templates: dict[str, str] | None = None
195
250
 
196
251
  def __str__(self) -> str:
197
- """String representation for display."""
252
+ """
253
+ String representation for display.
254
+ @athena: e5669be6329b
255
+ """
198
256
  if not self.arg_templates:
199
257
  return self.task_name
200
258
  args_str = ", ".join(f"{k}={v}" for k, v in self.arg_templates.items())
@@ -203,17 +261,23 @@ class DependencySpec:
203
261
 
204
262
  @dataclass
205
263
  class DependencyInvocation:
206
- """Represents a task dependency invocation with optional arguments.
264
+ """
265
+ Represents a task dependency invocation with optional arguments.
207
266
 
208
267
  Attributes:
209
- task_name: Name of the dependency task
210
- args: Dictionary of argument names to values (None if no args specified)
268
+ task_name: Name of the dependency task
269
+ args: Dictionary of argument names to values (None if no args specified)
270
+ @athena: 0c023366160b
211
271
  """
272
+
212
273
  task_name: str
213
274
  args: dict[str, Any] | None = None
214
275
 
215
276
  def __str__(self) -> str:
216
- """String representation for display."""
277
+ """
278
+ String representation for display.
279
+ @athena: 22fc0502192b
280
+ """
217
281
  if not self.args:
218
282
  return self.task_name
219
283
  args_str = ", ".join(f"{k}={v}" for k, v in self.args.items())
@@ -222,17 +286,20 @@ class DependencyInvocation:
222
286
 
223
287
  @dataclass
224
288
  class ArgSpec:
225
- """Represents a parsed argument specification.
289
+ """
290
+ Represents a parsed argument specification.
226
291
 
227
292
  Attributes:
228
- name: Argument name
229
- arg_type: Type of the argument (str, int, float, bool, path)
230
- default: Default value as a string (None if no default)
231
- is_exported: Whether the argument is exported as an environment variable
232
- min_val: Minimum value for numeric arguments (None if not specified)
233
- max_val: Maximum value for numeric arguments (None if not specified)
234
- choices: List of valid choices for the argument (None if not specified)
293
+ name: Argument name
294
+ arg_type: Type of the argument (str, int, float, bool, path)
295
+ default: Default value as a string (None if no default)
296
+ is_exported: Whether the argument is exported as an environment variable
297
+ min_val: Minimum value for numeric arguments (None if not specified)
298
+ max_val: Maximum value for numeric arguments (None if not specified)
299
+ choices: List of valid choices for the argument (None if not specified)
300
+ @athena: fcaf20fb1ca2
235
301
  """
302
+
236
303
  name: str
237
304
  arg_type: str
238
305
  default: str | None = None
@@ -244,7 +311,10 @@ class ArgSpec:
244
311
 
245
312
  @dataclass
246
313
  class Recipe:
247
- """Represents a parsed recipe file with all tasks."""
314
+ """
315
+ Represents a parsed recipe file with all tasks.
316
+ @athena: 47f568c77013
317
+ """
248
318
 
249
319
  tasks: dict[str, Task]
250
320
  project_root: Path
@@ -252,40 +322,56 @@ class Recipe:
252
322
  environments: dict[str, Environment] = field(default_factory=dict)
253
323
  default_env: str = "" # Name of default environment
254
324
  global_env_override: str = "" # Global environment override (set via CLI --env)
255
- variables: dict[str, str] = field(default_factory=dict) # Global variables (resolved at parse time) - DEPRECATED, use evaluated_variables
256
- raw_variables: dict[str, Any] = field(default_factory=dict) # Raw variable specs from YAML (not yet evaluated)
257
- evaluated_variables: dict[str, str] = field(default_factory=dict) # Evaluated variable values (cached after evaluation)
325
+ variables: dict[str, str] = field(
326
+ default_factory=dict
327
+ ) # Global variables (resolved at parse time) - DEPRECATED, use evaluated_variables
328
+ raw_variables: dict[str, Any] = field(
329
+ default_factory=dict
330
+ ) # Raw variable specs from YAML (not yet evaluated)
331
+ evaluated_variables: dict[str, str] = field(
332
+ default_factory=dict
333
+ ) # Evaluated variable values (cached after evaluation)
258
334
  _variables_evaluated: bool = False # Track if variables have been evaluated
259
- _original_yaml_data: dict[str, Any] = field(default_factory=dict) # Store original YAML data for lazy evaluation context
335
+ _original_yaml_data: dict[str, Any] = field(
336
+ default_factory=dict
337
+ ) # Store original YAML data for lazy evaluation context
260
338
 
261
339
  def get_task(self, name: str) -> Task | None:
262
- """Get task by name.
340
+ """
341
+ Get task by name.
263
342
 
264
343
  Args:
265
- name: Task name (may be namespaced like 'build.compile')
344
+ name: Task name (may be namespaced like 'build.compile')
266
345
 
267
346
  Returns:
268
- Task if found, None otherwise
347
+ Task if found, None otherwise
348
+ @athena: 3f8137d71757
269
349
  """
270
350
  return self.tasks.get(name)
271
351
 
272
352
  def task_names(self) -> list[str]:
273
- """Get all task names."""
353
+ """
354
+ Get all task names.
355
+ @athena: 1df54563a7b6
356
+ """
274
357
  return list(self.tasks.keys())
275
358
 
276
359
  def get_environment(self, name: str) -> Environment | None:
277
- """Get environment by name.
360
+ """
361
+ Get environment by name.
278
362
 
279
363
  Args:
280
- name: Environment name
364
+ name: Environment name
281
365
 
282
366
  Returns:
283
- Environment if found, None otherwise
367
+ Environment if found, None otherwise
368
+ @athena: 098227ca38a2
284
369
  """
285
370
  return self.environments.get(name)
286
371
 
287
372
  def evaluate_variables(self, root_task: str | None = None) -> None:
288
- """Evaluate variables lazily based on task reachability.
373
+ """
374
+ Evaluate variables lazily based on task reachability.
289
375
 
290
376
  This method implements lazy variable evaluation, which only evaluates
291
377
  variables that are actually reachable from the target task. This provides:
@@ -299,15 +385,16 @@ class Recipe:
299
385
  This method is idempotent - calling it multiple times is safe (uses caching).
300
386
 
301
387
  Args:
302
- root_task: Optional task name to determine reachability (None = evaluate all)
388
+ root_task: Optional task name to determine reachability (None = evaluate all)
303
389
 
304
390
  Raises:
305
- ValueError: If variable evaluation or substitution fails
391
+ ValueError: If variable evaluation or substitution fails
306
392
 
307
393
  Example:
308
- >>> recipe = parse_recipe(path) # Variables not yet evaluated
309
- >>> recipe.evaluate_variables("build") # Evaluate only reachable variables
310
- >>> # Now recipe.evaluated_variables contains only vars used by "build" task
394
+ >>> recipe = parse_recipe(path) # Variables not yet evaluated
395
+ >>> recipe.evaluate_variables("build") # Evaluate only reachable variables
396
+ >>> # Now recipe.evaluated_variables contains only vars used by "build" task
397
+ @athena: d8de7b5f42b6
311
398
  """
312
399
  if self._variables_evaluated:
313
400
  return # Already evaluated, skip (idempotent)
@@ -319,7 +406,9 @@ class Recipe:
319
406
  # (CLI will provide its own "Task not found" error)
320
407
  try:
321
408
  reachable_tasks = collect_reachable_tasks(self.tasks, root_task)
322
- variables_to_eval = collect_reachable_variables(self.tasks, self.environments, reachable_tasks)
409
+ variables_to_eval = collect_reachable_variables(
410
+ self.tasks, self.environments, reachable_tasks
411
+ )
323
412
  except ValueError:
324
413
  # Root task not found - fall back to eager evaluation
325
414
  # This allows the recipe to be parsed even with invalid task names
@@ -336,7 +425,7 @@ class Recipe:
336
425
  self.raw_variables,
337
426
  variables_to_eval,
338
427
  self.recipe_path,
339
- self._original_yaml_data
428
+ self._original_yaml_data,
340
429
  )
341
430
 
342
431
  # Also update the deprecated 'variables' field for backward compatibility
@@ -351,18 +440,24 @@ class Recipe:
351
440
 
352
441
  task.cmd = substitute_variables(task.cmd, self.evaluated_variables)
353
442
  task.desc = substitute_variables(task.desc, self.evaluated_variables)
354
- task.working_dir = substitute_variables(task.working_dir, self.evaluated_variables)
443
+ task.working_dir = substitute_variables(
444
+ task.working_dir, self.evaluated_variables
445
+ )
355
446
 
356
447
  # Substitute variables in inputs (handle both string and dict inputs)
357
448
  resolved_inputs = []
358
449
  for inp in task.inputs:
359
450
  if isinstance(inp, str):
360
- resolved_inputs.append(substitute_variables(inp, self.evaluated_variables))
451
+ resolved_inputs.append(
452
+ substitute_variables(inp, self.evaluated_variables)
453
+ )
361
454
  elif isinstance(inp, dict):
362
455
  # Named input: substitute the path value
363
456
  resolved_dict = {}
364
457
  for name, path in inp.items():
365
- resolved_dict[name] = substitute_variables(path, self.evaluated_variables)
458
+ resolved_dict[name] = substitute_variables(
459
+ path, self.evaluated_variables
460
+ )
366
461
  resolved_inputs.append(resolved_dict)
367
462
  else:
368
463
  resolved_inputs.append(inp)
@@ -372,12 +467,16 @@ class Recipe:
372
467
  resolved_outputs = []
373
468
  for out in task.outputs:
374
469
  if isinstance(out, str):
375
- resolved_outputs.append(substitute_variables(out, self.evaluated_variables))
470
+ resolved_outputs.append(
471
+ substitute_variables(out, self.evaluated_variables)
472
+ )
376
473
  elif isinstance(out, dict):
377
474
  # Named output: substitute the path value
378
475
  resolved_dict = {}
379
476
  for name, path in out.items():
380
- resolved_dict[name] = substitute_variables(path, self.evaluated_variables)
477
+ resolved_dict[name] = substitute_variables(
478
+ path, self.evaluated_variables
479
+ )
381
480
  resolved_outputs.append(resolved_dict)
382
481
  else:
383
482
  resolved_outputs.append(out)
@@ -390,7 +489,9 @@ class Recipe:
390
489
  resolved_args = []
391
490
  for arg in task.args:
392
491
  if isinstance(arg, str):
393
- resolved_args.append(substitute_variables(arg, self.evaluated_variables))
492
+ resolved_args.append(
493
+ substitute_variables(arg, self.evaluated_variables)
494
+ )
394
495
  elif isinstance(arg, dict):
395
496
  # Dict arg: substitute in nested values (like default values)
396
497
  resolved_dict = {}
@@ -400,11 +501,19 @@ class Recipe:
400
501
  resolved_spec = {}
401
502
  for key, value in arg_spec.items():
402
503
  if isinstance(value, str):
403
- resolved_spec[key] = substitute_variables(value, self.evaluated_variables)
504
+ resolved_spec[key] = substitute_variables(
505
+ value, self.evaluated_variables
506
+ )
404
507
  elif isinstance(value, list):
405
508
  # Handle lists like 'choices'
406
509
  resolved_spec[key] = [
407
- substitute_variables(v, self.evaluated_variables) if isinstance(v, str) else v
510
+ (
511
+ substitute_variables(
512
+ v, self.evaluated_variables
513
+ )
514
+ if isinstance(v, str)
515
+ else v
516
+ )
408
517
  for v in value
409
518
  ]
410
519
  else:
@@ -412,7 +521,11 @@ class Recipe:
412
521
  resolved_dict[arg_name] = resolved_spec
413
522
  else:
414
523
  # Simple value
415
- resolved_dict[arg_name] = substitute_variables(arg_spec, self.evaluated_variables) if isinstance(arg_spec, str) else arg_spec
524
+ resolved_dict[arg_name] = (
525
+ substitute_variables(arg_spec, self.evaluated_variables)
526
+ if isinstance(arg_spec, str)
527
+ else arg_spec
528
+ )
416
529
  resolved_args.append(resolved_dict)
417
530
  else:
418
531
  resolved_args.append(arg)
@@ -421,15 +534,23 @@ class Recipe:
421
534
  # Substitute evaluated variables into all environments
422
535
  for env in self.environments.values():
423
536
  if env.preamble:
424
- env.preamble = substitute_variables(env.preamble, self.evaluated_variables)
537
+ env.preamble = substitute_variables(
538
+ env.preamble, self.evaluated_variables
539
+ )
425
540
 
426
541
  # Substitute in volumes
427
542
  if env.volumes:
428
- env.volumes = [substitute_variables(vol, self.evaluated_variables) for vol in env.volumes]
543
+ env.volumes = [
544
+ substitute_variables(vol, self.evaluated_variables)
545
+ for vol in env.volumes
546
+ ]
429
547
 
430
548
  # Substitute in ports
431
549
  if env.ports:
432
- env.ports = [substitute_variables(port, self.evaluated_variables) for port in env.ports]
550
+ env.ports = [
551
+ substitute_variables(port, self.evaluated_variables)
552
+ for port in env.ports
553
+ ]
433
554
 
434
555
  # Substitute in env_vars values
435
556
  if env.env_vars:
@@ -440,7 +561,9 @@ class Recipe:
440
561
 
441
562
  # Substitute in working_dir
442
563
  if env.working_dir:
443
- env.working_dir = substitute_variables(env.working_dir, self.evaluated_variables)
564
+ env.working_dir = substitute_variables(
565
+ env.working_dir, self.evaluated_variables
566
+ )
444
567
 
445
568
  # Substitute in build args (dict for Docker environments)
446
569
  if env.args and isinstance(env.args, dict):
@@ -454,7 +577,8 @@ class Recipe:
454
577
 
455
578
 
456
579
  def find_recipe_file(start_dir: Path | None = None) -> Path | None:
457
- """Find recipe file in current or parent directories.
580
+ """
581
+ Find recipe file in current or parent directories.
458
582
 
459
583
  Looks for recipe files matching these patterns (in order of preference):
460
584
  - tasktree.yaml
@@ -466,13 +590,14 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
466
590
  with instructions to use --tasks option.
467
591
 
468
592
  Args:
469
- start_dir: Directory to start searching from (defaults to cwd)
593
+ start_dir: Directory to start searching from (defaults to cwd)
470
594
 
471
595
  Returns:
472
- Path to recipe file if found, None otherwise
596
+ Path to recipe file if found, None otherwise
473
597
 
474
598
  Raises:
475
- ValueError: If multiple recipe files found in the same directory
599
+ ValueError: If multiple recipe files found in the same directory
600
+ @athena: 38ccb0c1bb86
476
601
  """
477
602
  if start_dir is None:
478
603
  start_dir = Path.cwd()
@@ -532,15 +657,17 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
532
657
 
533
658
 
534
659
  def _validate_variable_name(name: str) -> None:
535
- """Validate that a variable name is a valid identifier.
660
+ """
661
+ Validate that a variable name is a valid identifier.
536
662
 
537
663
  Args:
538
- name: Variable name to validate
664
+ name: Variable name to validate
539
665
 
540
666
  Raises:
541
- ValueError: If name is not a valid identifier
667
+ ValueError: If name is not a valid identifier
668
+ @athena: 61f92f7ad278
542
669
  """
543
- if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
670
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
544
671
  raise ValueError(
545
672
  f"Variable name '{name}' is invalid. Names must start with "
546
673
  f"letter/underscore and contain only alphanumerics and underscores."
@@ -548,23 +675,20 @@ def _validate_variable_name(name: str) -> None:
548
675
 
549
676
 
550
677
  def _infer_variable_type(value: Any) -> str:
551
- """Infer type name from Python value.
678
+ """
679
+ Infer type name from Python value.
552
680
 
553
681
  Args:
554
- value: Python value from YAML
682
+ value: Python value from YAML
555
683
 
556
684
  Returns:
557
- Type name string (str, int, float, bool)
685
+ Type name string (str, int, float, bool)
558
686
 
559
687
  Raises:
560
- ValueError: If value type is not supported
561
- """
562
- type_map = {
563
- str: "str",
564
- int: "int",
565
- float: "float",
566
- bool: "bool"
567
- }
688
+ ValueError: If value type is not supported
689
+ @athena: 335ae24e1504
690
+ """
691
+ type_map = {str: "str", int: "int", float: "float", bool: "bool"}
568
692
  python_type = type(value)
569
693
  if python_type not in type_map:
570
694
  raise ValueError(
@@ -575,29 +699,35 @@ def _infer_variable_type(value: Any) -> str:
575
699
 
576
700
 
577
701
  def _is_env_variable_reference(value: Any) -> bool:
578
- """Check if value is an environment variable reference.
702
+ """
703
+ Check if value is an environment variable reference.
579
704
 
580
705
  Args:
581
- value: Raw value from YAML
706
+ value: Raw value from YAML
582
707
 
583
708
  Returns:
584
- True if value is { env: VAR_NAME } dict
709
+ True if value is { env: VAR_NAME } dict
710
+ @athena: c01927ec19ef
585
711
  """
586
712
  return isinstance(value, dict) and "env" in value
587
713
 
588
714
 
589
- def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, str | None]:
590
- """Validate and extract environment variable name and optional default from reference.
715
+ def _validate_env_variable_reference(
716
+ var_name: str, value: dict
717
+ ) -> tuple[str, str | None]:
718
+ """
719
+ Validate and extract environment variable name and optional default from reference.
591
720
 
592
721
  Args:
593
- var_name: Name of the variable being defined
594
- value: Dict that should be { env: ENV_VAR_NAME } or { env: ENV_VAR_NAME, default: "value" }
722
+ var_name: Name of the variable being defined
723
+ value: Dict that should be { env: ENV_VAR_NAME } or { env: ENV_VAR_NAME, default: "value" }
595
724
 
596
725
  Returns:
597
- Tuple of (environment variable name, default value or None)
726
+ Tuple of (environment variable name, default value or None)
598
727
 
599
728
  Raises:
600
- ValueError: If reference is invalid
729
+ ValueError: If reference is invalid
730
+ @athena: 9fc8b2333b54
601
731
  """
602
732
  # Validate dict structure - allow 'env' and optionally 'default'
603
733
  valid_keys = {"env", "default"}
@@ -605,7 +735,7 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
605
735
  if invalid_keys:
606
736
  raise ValueError(
607
737
  f"Invalid environment variable reference in variable '{var_name}'.\n"
608
- f"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}\n"
738
+ f'Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: "value" }}\n'
609
739
  f"Found invalid keys: {', '.join(invalid_keys)}"
610
740
  )
611
741
 
@@ -614,7 +744,7 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
614
744
  raise ValueError(
615
745
  f"Invalid environment variable reference in variable '{var_name}'.\n"
616
746
  f"Missing required 'env' key.\n"
617
- f"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}"
747
+ f'Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: "value" }}'
618
748
  )
619
749
 
620
750
  env_var_name = value["env"]
@@ -623,12 +753,12 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
623
753
  if not env_var_name or not isinstance(env_var_name, str):
624
754
  raise ValueError(
625
755
  f"Invalid environment variable reference in variable '{var_name}'.\n"
626
- f"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}"
756
+ f'Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: "value" }}'
627
757
  f"Found: {{ env: {env_var_name!r} }}"
628
758
  )
629
759
 
630
760
  # Validate env var name format (allow both uppercase and mixed case for flexibility)
631
- if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', env_var_name):
761
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", env_var_name):
632
762
  raise ValueError(
633
763
  f"Invalid environment variable name '{env_var_name}' in variable '{var_name}'.\n"
634
764
  f"Environment variable names must start with a letter or underscore,\n"
@@ -644,25 +774,29 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
644
774
  f"Invalid default value in variable '{var_name}'.\n"
645
775
  f"Environment variable defaults must be strings.\n"
646
776
  f"Got: {default!r} (type: {type(default).__name__})\n"
647
- f"Use a quoted string: {{ env: {env_var_name}, default: \"{default}\" }}"
777
+ f'Use a quoted string: {{ env: {env_var_name}, default: "{default}" }}'
648
778
  )
649
779
 
650
780
  return env_var_name, default
651
781
 
652
782
 
653
- def _resolve_env_variable(var_name: str, env_var_name: str, default: str | None = None) -> str:
654
- """Resolve environment variable value.
783
+ def _resolve_env_variable(
784
+ var_name: str, env_var_name: str, default: str | None = None
785
+ ) -> str:
786
+ """
787
+ Resolve environment variable value.
655
788
 
656
789
  Args:
657
- var_name: Name of the variable being defined
658
- env_var_name: Name of environment variable to read
659
- default: Optional default value to use if environment variable is not set
790
+ var_name: Name of the variable being defined
791
+ env_var_name: Name of environment variable to read
792
+ default: Optional default value to use if environment variable is not set
660
793
 
661
794
  Returns:
662
- Environment variable value as string, or default if not set and default provided
795
+ Environment variable value as string, or default if not set and default provided
663
796
 
664
797
  Raises:
665
- ValueError: If environment variable is not set and no default provided
798
+ ValueError: If environment variable is not set and no default provided
799
+ @athena: c00d3a241a99
666
800
  """
667
801
  value = os.environ.get(env_var_name, default)
668
802
 
@@ -680,29 +814,33 @@ def _resolve_env_variable(var_name: str, env_var_name: str, default: str | None
680
814
 
681
815
 
682
816
  def _is_file_read_reference(value: Any) -> bool:
683
- """Check if value is a file read reference.
817
+ """
818
+ Check if value is a file read reference.
684
819
 
685
820
  Args:
686
- value: Raw value from YAML
821
+ value: Raw value from YAML
687
822
 
688
823
  Returns:
689
- True if value is { read: filepath } dict
824
+ True if value is { read: filepath } dict
825
+ @athena: da129db1b17b
690
826
  """
691
827
  return isinstance(value, dict) and "read" in value
692
828
 
693
829
 
694
830
  def _validate_file_read_reference(var_name: str, value: dict) -> str:
695
- """Validate and extract filepath from file read reference.
831
+ """
832
+ Validate and extract filepath from file read reference.
696
833
 
697
834
  Args:
698
- var_name: Name of the variable being defined
699
- value: Dict that should be { read: filepath }
835
+ var_name: Name of the variable being defined
836
+ value: Dict that should be { read: filepath }
700
837
 
701
838
  Returns:
702
- Filepath string
839
+ Filepath string
703
840
 
704
841
  Raises:
705
- ValueError: If reference is invalid
842
+ ValueError: If reference is invalid
843
+ @athena: 2615951372fc
706
844
  """
707
845
  # Validate dict structure (only "read" key allowed)
708
846
  if len(value) != 1:
@@ -728,7 +866,8 @@ def _validate_file_read_reference(var_name: str, value: dict) -> str:
728
866
 
729
867
 
730
868
  def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
731
- """Resolve file path relative to recipe file location.
869
+ """
870
+ Resolve file path relative to recipe file location.
732
871
 
733
872
  Handles three path types:
734
873
  1. Tilde paths (~): Expand to user home directory
@@ -736,11 +875,12 @@ def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
736
875
  3. Relative paths: Resolve relative to recipe file's directory
737
876
 
738
877
  Args:
739
- filepath: Path string from YAML (may be relative, absolute, or tilde)
740
- recipe_file_path: Path to the recipe file containing the variable
878
+ filepath: Path string from YAML (may be relative, absolute, or tilde)
879
+ recipe_file_path: Path to the recipe file containing the variable
741
880
 
742
881
  Returns:
743
- Resolved absolute Path object
882
+ Resolved absolute Path object
883
+ @athena: e80470e9c7d6
744
884
  """
745
885
  # Expand tilde to home directory
746
886
  if filepath.startswith("~"):
@@ -758,18 +898,20 @@ def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
758
898
 
759
899
 
760
900
  def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) -> str:
761
- """Read file contents for variable value.
901
+ """
902
+ Read file contents for variable value.
762
903
 
763
904
  Args:
764
- var_name: Name of the variable being defined
765
- filepath: Original filepath string (for error messages)
766
- resolved_path: Resolved absolute path to the file
905
+ var_name: Name of the variable being defined
906
+ filepath: Original filepath string (for error messages)
907
+ resolved_path: Resolved absolute path to the file
767
908
 
768
909
  Returns:
769
- File contents as string (with trailing newline stripped)
910
+ File contents as string (with trailing newline stripped)
770
911
 
771
912
  Raises:
772
- ValueError: If file doesn't exist, can't be read, or contains invalid UTF-8
913
+ ValueError: If file doesn't exist, can't be read, or contains invalid UTF-8
914
+ @athena: cab84337f145
773
915
  """
774
916
  # Check file exists
775
917
  if not resolved_path.exists():
@@ -788,7 +930,7 @@ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) ->
788
930
 
789
931
  # Read file with UTF-8 error handling
790
932
  try:
791
- content = resolved_path.read_text(encoding='utf-8')
933
+ content = resolved_path.read_text(encoding="utf-8")
792
934
  except PermissionError:
793
935
  raise ValueError(
794
936
  f"Failed to read file for variable '{var_name}': {filepath}\n"
@@ -804,36 +946,40 @@ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) ->
804
946
  )
805
947
 
806
948
  # Strip single trailing newline if present
807
- if content.endswith('\n'):
949
+ if content.endswith("\n"):
808
950
  content = content[:-1]
809
951
 
810
952
  return content
811
953
 
812
954
 
813
955
  def _is_eval_reference(value: Any) -> bool:
814
- """Check if value is an eval command reference.
956
+ """
957
+ Check if value is an eval command reference.
815
958
 
816
959
  Args:
817
- value: Raw value from YAML
960
+ value: Raw value from YAML
818
961
 
819
962
  Returns:
820
- True if value is { eval: command } dict
963
+ True if value is { eval: command } dict
964
+ @athena: 121784f6d4ab
821
965
  """
822
966
  return isinstance(value, dict) and "eval" in value
823
967
 
824
968
 
825
969
  def _validate_eval_reference(var_name: str, value: dict) -> str:
826
- """Validate and extract command from eval reference.
970
+ """
971
+ Validate and extract command from eval reference.
827
972
 
828
973
  Args:
829
- var_name: Name of the variable being defined
830
- value: Dict that should be { eval: command }
974
+ var_name: Name of the variable being defined
975
+ value: Dict that should be { eval: command }
831
976
 
832
977
  Returns:
833
- Command string
978
+ Command string
834
979
 
835
980
  Raises:
836
- ValueError: If reference is invalid
981
+ ValueError: If reference is invalid
982
+ @athena: f3cde1011d2d
837
983
  """
838
984
  # Validate dict structure (only "eval" key allowed)
839
985
  if len(value) != 1:
@@ -859,37 +1005,38 @@ def _validate_eval_reference(var_name: str, value: dict) -> str:
859
1005
 
860
1006
 
861
1007
  def _get_default_shell_and_args() -> tuple[str, list[str]]:
862
- """Get default shell and args for current platform.
1008
+ """
1009
+ Get default shell and args for current platform.
863
1010
 
864
1011
  Returns:
865
- Tuple of (shell, args) for platform default
1012
+ Tuple of (shell, args) for platform default
1013
+ @athena: 475863b02b48
866
1014
  """
867
1015
  is_windows = platform.system() == "Windows"
868
1016
  if is_windows:
869
- return ("cmd", ["/c"])
1017
+ return "cmd", ["/c"]
870
1018
  else:
871
- return ("bash", ["-c"])
1019
+ return "bash", ["-c"]
872
1020
 
873
1021
 
874
1022
  def _resolve_eval_variable(
875
- var_name: str,
876
- command: str,
877
- recipe_file_path: Path,
878
- recipe_data: dict
1023
+ var_name: str, command: str, recipe_file_path: Path, recipe_data: dict
879
1024
  ) -> str:
880
- """Execute command and capture output for variable value.
1025
+ """
1026
+ Execute command and capture output for variable value.
881
1027
 
882
1028
  Args:
883
- var_name: Name of the variable being defined
884
- command: Command to execute
885
- recipe_file_path: Path to recipe file (for working directory)
886
- recipe_data: Parsed YAML data (for accessing default_env)
1029
+ var_name: Name of the variable being defined
1030
+ command: Command to execute
1031
+ recipe_file_path: Path to recipe file (for working directory)
1032
+ recipe_data: Parsed YAML data (for accessing default_env)
887
1033
 
888
1034
  Returns:
889
- Command stdout as string (with trailing newline stripped)
1035
+ Command stdout as string (with trailing newline stripped)
890
1036
 
891
1037
  Raises:
892
- ValueError: If command fails or cannot be executed
1038
+ ValueError: If command fails or cannot be executed
1039
+ @athena: 647d3a310c77
893
1040
  """
894
1041
  # Determine shell to use
895
1042
  shell = None
@@ -954,7 +1101,7 @@ def _resolve_eval_variable(
954
1101
  output = result.stdout
955
1102
 
956
1103
  # Strip single trailing newline if present
957
- if output.endswith('\n'):
1104
+ if output.endswith("\n"):
958
1105
  output = output[:-1]
959
1106
 
960
1107
  return output
@@ -966,23 +1113,25 @@ def _resolve_variable_value(
966
1113
  resolved: dict[str, str],
967
1114
  resolution_stack: list[str],
968
1115
  file_path: Path,
969
- recipe_data: dict | None = None
1116
+ recipe_data: dict | None = None,
970
1117
  ) -> str:
971
- """Resolve a single variable value with circular reference detection.
1118
+ """
1119
+ Resolve a single variable value with circular reference detection.
972
1120
 
973
1121
  Args:
974
- name: Variable name being resolved
975
- raw_value: Raw value from YAML (int, str, bool, float, dict with env/read/eval)
976
- resolved: Dictionary of already-resolved variables
977
- resolution_stack: Stack of variables currently being resolved (for circular detection)
978
- file_path: Path to recipe file (for resolving relative file paths in { read: ... })
979
- recipe_data: Parsed YAML data (for accessing default_env in { eval: ... })
1122
+ name: Variable name being resolved
1123
+ raw_value: Raw value from YAML (int, str, bool, float, dict with env/read/eval)
1124
+ resolved: Dictionary of already-resolved variables
1125
+ resolution_stack: Stack of variables currently being resolved (for circular detection)
1126
+ file_path: Path to recipe file (for resolving relative file paths in { read: ... })
1127
+ recipe_data: Parsed YAML data (for accessing default_env in { eval: ... })
980
1128
 
981
1129
  Returns:
982
- Resolved string value
1130
+ Resolved string value
983
1131
 
984
1132
  Raises:
985
- ValueError: If circular reference detected or validation fails
1133
+ ValueError: If circular reference detected or validation fails
1134
+ @athena: da94de106756
986
1135
  """
987
1136
  # Check for circular reference
988
1137
  if name in resolution_stack:
@@ -1002,6 +1151,7 @@ def _resolve_variable_value(
1002
1151
 
1003
1152
  # Still perform variable-in-variable substitution
1004
1153
  from tasktree.substitution import substitute_variables
1154
+
1005
1155
  try:
1006
1156
  resolved_value = substitute_variables(string_value, resolved)
1007
1157
  except ValueError as e:
@@ -1013,7 +1163,9 @@ def _resolve_variable_value(
1013
1163
  undefined_var = match.group(1)
1014
1164
  if undefined_var in resolution_stack:
1015
1165
  cycle = " -> ".join(resolution_stack + [undefined_var])
1016
- raise ValueError(f"Circular reference detected in variables: {cycle}")
1166
+ raise ValueError(
1167
+ f"Circular reference detected in variables: {cycle}"
1168
+ )
1017
1169
  # Re-raise the original error if not circular
1018
1170
  raise
1019
1171
 
@@ -1032,6 +1184,7 @@ def _resolve_variable_value(
1032
1184
 
1033
1185
  # Still perform variable-in-variable substitution
1034
1186
  from tasktree.substitution import substitute_variables
1187
+
1035
1188
  try:
1036
1189
  resolved_value = substitute_variables(string_value, resolved)
1037
1190
  except ValueError as e:
@@ -1043,7 +1196,9 @@ def _resolve_variable_value(
1043
1196
  undefined_var = match.group(1)
1044
1197
  if undefined_var in resolution_stack:
1045
1198
  cycle = " -> ".join(resolution_stack + [undefined_var])
1046
- raise ValueError(f"Circular reference detected in variables: {cycle}")
1199
+ raise ValueError(
1200
+ f"Circular reference detected in variables: {cycle}"
1201
+ )
1047
1202
  # Re-raise the original error if not circular
1048
1203
  raise
1049
1204
 
@@ -1059,6 +1214,7 @@ def _resolve_variable_value(
1059
1214
 
1060
1215
  # Still perform variable-in-variable substitution
1061
1216
  from tasktree.substitution import substitute_variables
1217
+
1062
1218
  try:
1063
1219
  resolved_value = substitute_variables(string_value, resolved)
1064
1220
  except ValueError as e:
@@ -1070,7 +1226,9 @@ def _resolve_variable_value(
1070
1226
  undefined_var = match.group(1)
1071
1227
  if undefined_var in resolution_stack:
1072
1228
  cycle = " -> ".join(resolution_stack + [undefined_var])
1073
- raise ValueError(f"Circular reference detected in variables: {cycle}")
1229
+ raise ValueError(
1230
+ f"Circular reference detected in variables: {cycle}"
1231
+ )
1074
1232
  # Re-raise the original error if not circular
1075
1233
  raise
1076
1234
 
@@ -1079,6 +1237,7 @@ def _resolve_variable_value(
1079
1237
  # Validate and infer type
1080
1238
  type_name = _infer_variable_type(raw_value)
1081
1239
  from tasktree.types import get_click_type
1240
+
1082
1241
  validator = get_click_type(type_name)
1083
1242
 
1084
1243
  # Validate and stringify the value
@@ -1092,6 +1251,7 @@ def _resolve_variable_value(
1092
1251
 
1093
1252
  # Substitute any {{ var.name }} references in the string value
1094
1253
  from tasktree.substitution import substitute_variables
1254
+
1095
1255
  try:
1096
1256
  resolved_value = substitute_variables(string_value_str, resolved)
1097
1257
  except ValueError as e:
@@ -1104,7 +1264,9 @@ def _resolve_variable_value(
1104
1264
  undefined_var = match.group(1)
1105
1265
  if undefined_var in resolution_stack:
1106
1266
  cycle = " -> ".join(resolution_stack + [undefined_var])
1107
- raise ValueError(f"Circular reference detected in variables: {cycle}")
1267
+ raise ValueError(
1268
+ f"Circular reference detected in variables: {cycle}"
1269
+ )
1108
1270
  # Re-raise the original error if not circular
1109
1271
  raise
1110
1272
 
@@ -1114,20 +1276,22 @@ def _resolve_variable_value(
1114
1276
 
1115
1277
 
1116
1278
  def _parse_variables_section(data: dict, file_path: Path) -> dict[str, str]:
1117
- """Parse and resolve the variables section from YAML data.
1279
+ """
1280
+ Parse and resolve the variables section from YAML data.
1118
1281
 
1119
1282
  Variables are resolved in order, allowing variables to reference
1120
1283
  previously-defined variables using {{ var.name }} syntax.
1121
1284
 
1122
1285
  Args:
1123
- data: Parsed YAML data (root level)
1124
- file_path: Path to the recipe file (for resolving relative file paths)
1286
+ data: Parsed YAML data (root level)
1287
+ file_path: Path to the recipe file (for resolving relative file paths)
1125
1288
 
1126
1289
  Returns:
1127
- Dictionary mapping variable names to resolved string values
1290
+ Dictionary mapping variable names to resolved string values
1128
1291
 
1129
1292
  Raises:
1130
- ValueError: For validation errors, undefined refs, or circular refs
1293
+ ValueError: For validation errors, undefined refs, or circular refs
1294
+ @athena: aa45e860a958
1131
1295
  """
1132
1296
  if "variables" not in data:
1133
1297
  return {}
@@ -1149,33 +1313,34 @@ def _parse_variables_section(data: dict, file_path: Path) -> dict[str, str]:
1149
1313
 
1150
1314
 
1151
1315
  def _expand_variable_dependencies(
1152
- variable_names: set[str],
1153
- raw_variables: dict[str, Any]
1316
+ variable_names: set[str], raw_variables: dict[str, Any]
1154
1317
  ) -> set[str]:
1155
- """Expand variable set to include all transitively referenced variables.
1318
+ """
1319
+ Expand variable set to include all transitively referenced variables.
1156
1320
 
1157
1321
  If variable A references variable B, and B references C, then requesting A
1158
1322
  should also evaluate B and C.
1159
1323
 
1160
1324
  Args:
1161
- variable_names: Initial set of variable names
1162
- raw_variables: Raw variable definitions from YAML
1325
+ variable_names: Initial set of variable names
1326
+ raw_variables: Raw variable definitions from YAML
1163
1327
 
1164
1328
  Returns:
1165
- Expanded set including all transitively referenced variables
1329
+ Expanded set including all transitively referenced variables
1166
1330
 
1167
1331
  Example:
1168
- >>> raw_vars = {
1169
- ... "a": "{{ var.b }}",
1170
- ... "b": "{{ var.c }}",
1171
- ... "c": "value"
1172
- ... }
1173
- >>> _expand_variable_dependencies({"a"}, raw_vars)
1174
- {"a", "b", "c"}
1332
+ >>> raw_vars = {
1333
+ ... "a": "{{ var.b }}",
1334
+ ... "b": "{{ var.c }}",
1335
+ ... "c": "value"
1336
+ ... }
1337
+ >>> _expand_variable_dependencies({"a"}, raw_vars)
1338
+ {"a", "b", "c"}
1339
+ @athena: 98e583b402aa
1175
1340
  """
1176
1341
  expanded = set(variable_names)
1177
1342
  to_process = list(variable_names)
1178
- pattern = re.compile(r'\{\{\s*var\.(\w+)\s*\}\}')
1343
+ pattern = re.compile(r"\{\{\s*var\.(\w+)\s*}}")
1179
1344
 
1180
1345
  while to_process:
1181
1346
  var_name = to_process.pop(0)
@@ -1194,8 +1359,8 @@ def _expand_variable_dependencies(
1194
1359
  expanded.add(referenced_var)
1195
1360
  to_process.append(referenced_var)
1196
1361
  # Handle { read: filepath } variables - check file contents for variable references
1197
- elif isinstance(raw_value, dict) and 'read' in raw_value:
1198
- filepath = raw_value['read']
1362
+ elif isinstance(raw_value, dict) and "read" in raw_value:
1363
+ filepath = raw_value["read"]
1199
1364
  # For dependency expansion, we speculatively read files to find variable references
1200
1365
  # This is acceptable because file reads are relatively cheap compared to eval commands
1201
1366
  try:
@@ -1203,6 +1368,7 @@ def _expand_variable_dependencies(
1203
1368
  # Skip if filepath is None or empty (validation error will be caught during evaluation)
1204
1369
  if filepath and isinstance(filepath, str):
1205
1370
  from pathlib import Path
1371
+
1206
1372
  if Path(filepath).exists():
1207
1373
  file_content = Path(filepath).read_text()
1208
1374
  # Extract variable references from file content
@@ -1216,8 +1382,12 @@ def _expand_variable_dependencies(
1216
1382
  # The error will be caught during actual evaluation
1217
1383
  pass
1218
1384
  # Handle { env: VAR, default: ... } variables - check default value for variable references
1219
- elif isinstance(raw_value, dict) and 'env' in raw_value and 'default' in raw_value:
1220
- default_value = raw_value['default']
1385
+ elif (
1386
+ isinstance(raw_value, dict)
1387
+ and "env" in raw_value
1388
+ and "default" in raw_value
1389
+ ):
1390
+ default_value = raw_value["default"]
1221
1391
  # Check if default value contains variable references
1222
1392
  if isinstance(default_value, str):
1223
1393
  for match in pattern.finditer(default_value):
@@ -1230,12 +1400,10 @@ def _expand_variable_dependencies(
1230
1400
 
1231
1401
 
1232
1402
  def _evaluate_variable_subset(
1233
- raw_variables: dict[str, Any],
1234
- variable_names: set[str],
1235
- file_path: Path,
1236
- data: dict
1403
+ raw_variables: dict[str, Any], variable_names: set[str], file_path: Path, data: dict
1237
1404
  ) -> dict[str, str]:
1238
- """Evaluate only specified variables from raw specs (for lazy evaluation).
1405
+ """
1406
+ Evaluate only specified variables from raw specs (for lazy evaluation).
1239
1407
 
1240
1408
  This function is similar to _parse_variables_section but only evaluates
1241
1409
  a subset of variables. This enables lazy evaluation where only reachable
@@ -1245,21 +1413,22 @@ def _evaluate_variable_subset(
1245
1413
  variable B, both will be evaluated even if only A was explicitly requested.
1246
1414
 
1247
1415
  Args:
1248
- raw_variables: Raw variable definitions from YAML (not yet evaluated)
1249
- variable_names: Set of variable names to evaluate
1250
- file_path: Recipe file path (for relative file resolution)
1251
- data: Full YAML data (for context in _resolve_variable_value)
1416
+ raw_variables: Raw variable definitions from YAML (not yet evaluated)
1417
+ variable_names: Set of variable names to evaluate
1418
+ file_path: Recipe file path (for relative file resolution)
1419
+ data: Full YAML data (for context in _resolve_variable_value)
1252
1420
 
1253
1421
  Returns:
1254
- Dictionary of evaluated variable values (for specified variables and their dependencies)
1422
+ Dictionary of evaluated variable values (for specified variables and their dependencies)
1255
1423
 
1256
1424
  Raises:
1257
- ValueError: For validation errors, undefined refs, or circular refs
1425
+ ValueError: For validation errors, undefined refs, or circular refs
1258
1426
 
1259
1427
  Example:
1260
- >>> raw_vars = {"a": "{{ var.b }}", "b": "value", "c": "unused"}
1261
- >>> _evaluate_variable_subset(raw_vars, {"a"}, path, data)
1262
- {"a": "value", "b": "value"} # "a" and its dependency "b", but not "c"
1428
+ >>> raw_vars = {"a": "{{ var.b }}", "b": "value", "c": "unused"}
1429
+ >>> _evaluate_variable_subset(raw_vars, {"a"}, path, data)
1430
+ {"a": "value", "b": "value"} # "a" and its dependency "b", but not "c"
1431
+ @athena: 1e9d491d7404
1263
1432
  """
1264
1433
  if not isinstance(raw_variables, dict):
1265
1434
  raise ValueError("'variables' must be a dictionary")
@@ -1286,18 +1455,22 @@ def _parse_file_with_env(
1286
1455
  namespace: str | None,
1287
1456
  project_root: Path,
1288
1457
  import_stack: list[Path] | None = None,
1289
- ) -> tuple[dict[str, Task], dict[str, Environment], str, dict[str, Any], dict[str, Any]]:
1290
- """Parse file and extract tasks, environments, and variables.
1458
+ ) -> tuple[
1459
+ dict[str, Task], dict[str, Environment], str, dict[str, Any], dict[str, Any]
1460
+ ]:
1461
+ """
1462
+ Parse file and extract tasks, environments, and variables.
1291
1463
 
1292
1464
  Args:
1293
- file_path: Path to YAML file
1294
- namespace: Optional namespace prefix for tasks
1295
- project_root: Root directory of the project
1296
- import_stack: Stack of files being imported (for circular detection)
1465
+ file_path: Path to YAML file
1466
+ namespace: Optional namespace prefix for tasks
1467
+ project_root: Root directory of the project
1468
+ import_stack: Stack of files being imported (for circular detection)
1297
1469
 
1298
1470
  Returns:
1299
- Tuple of (tasks, environments, default_env_name, raw_variables, yaml_data)
1300
- Note: Variables are NOT evaluated here - they're stored as raw specs for lazy evaluation
1471
+ Tuple of (tasks, environments, default_env_name, raw_variables, YAML_data)
1472
+ Note: Variables are NOT evaluated here - they're stored as raw specs for lazy evaluation
1473
+ @athena: b2dced506787
1301
1474
  """
1302
1475
  # Parse tasks normally
1303
1476
  tasks = _parse_file(file_path, namespace, project_root, import_stack)
@@ -1413,32 +1586,34 @@ def _parse_file_with_env(
1413
1586
  env_vars=env_vars,
1414
1587
  working_dir=working_dir,
1415
1588
  extra_args=extra_args,
1416
- run_as_root=run_as_root
1589
+ run_as_root=run_as_root,
1417
1590
  )
1418
1591
 
1419
1592
  return tasks, environments, default_env, raw_variables, yaml_data
1420
1593
 
1421
1594
 
1422
1595
  def collect_reachable_tasks(tasks: dict[str, Task], root_task: str) -> set[str]:
1423
- """Collect all tasks reachable from the root task via dependencies.
1596
+ """
1597
+ Collect all tasks reachable from the root task via dependencies.
1424
1598
 
1425
1599
  Uses BFS to traverse the dependency graph and collect all task names
1426
1600
  that could potentially be executed when running the root task.
1427
1601
 
1428
1602
  Args:
1429
- tasks: Dictionary mapping task names to Task objects
1430
- root_task: Name of the root task to start traversal from
1603
+ tasks: Dictionary mapping task names to Task objects
1604
+ root_task: Name of the root task to start traversal from
1431
1605
 
1432
1606
  Returns:
1433
- Set of task names reachable from root_task (includes root_task itself)
1607
+ Set of task names reachable from root_task (includes root_task itself)
1434
1608
 
1435
1609
  Raises:
1436
- ValueError: If root_task doesn't exist
1610
+ ValueError: If root_task doesn't exist
1437
1611
 
1438
1612
  Example:
1439
- >>> tasks = {"a": Task("a", deps=["b"]), "b": Task("b", deps=[]), "c": Task("c", deps=[])}
1440
- >>> collect_reachable_tasks(tasks, "a")
1441
- {"a", "b"}
1613
+ >>> tasks = {"a": Task("a", deps=["b"]), "b": Task("b", deps=[]), "c": Task("c", deps=[])}
1614
+ >>> collect_reachable_tasks(tasks, "a")
1615
+ {"a", "b"}
1616
+ @athena: fe29d8558be3
1442
1617
  """
1443
1618
  if root_task not in tasks:
1444
1619
  raise ValueError(f"Root task '{root_task}' not found in recipe")
@@ -1480,30 +1655,32 @@ def collect_reachable_tasks(tasks: dict[str, Task], root_task: str) -> set[str]:
1480
1655
  def collect_reachable_variables(
1481
1656
  tasks: dict[str, Task],
1482
1657
  environments: dict[str, Environment],
1483
- reachable_task_names: set[str]
1658
+ reachable_task_names: set[str],
1484
1659
  ) -> set[str]:
1485
- """Extract variable names used by reachable tasks.
1660
+ """
1661
+ Extract variable names used by reachable tasks.
1486
1662
 
1487
1663
  Searches for {{ var.* }} placeholders in task and environment definitions to determine
1488
1664
  which variables are actually needed for execution.
1489
1665
 
1490
1666
  Args:
1491
- tasks: Dictionary mapping task names to Task objects
1492
- environments: Dictionary mapping environment names to Environment objects
1493
- reachable_task_names: Set of task names that will be executed
1667
+ tasks: Dictionary mapping task names to Task objects
1668
+ environments: Dictionary mapping environment names to Environment objects
1669
+ reachable_task_names: Set of task names that will be executed
1494
1670
 
1495
1671
  Returns:
1496
- Set of variable names referenced by reachable tasks
1672
+ Set of variable names referenced by reachable tasks
1497
1673
 
1498
1674
  Example:
1499
- >>> task = Task("build", cmd="echo {{ var.version }}")
1500
- >>> collect_reachable_variables({"build": task}, {"build"})
1501
- {"version"}
1675
+ >>> task = Task("build", cmd="echo {{ var.version }}")
1676
+ >>> collect_reachable_variables({"build": task}, {"build"})
1677
+ {"version"}
1678
+ @athena: e22e54537f8d
1502
1679
  """
1503
1680
  import re
1504
1681
 
1505
1682
  # Pattern to match {{ var.name }}
1506
- var_pattern = re.compile(r'\{\{\s*var\s*\.\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}')
1683
+ var_pattern = re.compile(r"\{\{\s*var\s*\.\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*}}")
1507
1684
 
1508
1685
  variables = set()
1509
1686
 
@@ -1622,32 +1799,32 @@ def collect_reachable_variables(
1622
1799
 
1623
1800
 
1624
1801
  def parse_recipe(
1625
- recipe_path: Path,
1626
- project_root: Path | None = None,
1627
- root_task: str | None = None
1802
+ recipe_path: Path, project_root: Path | None = None, root_task: str | None = None
1628
1803
  ) -> Recipe:
1629
- """Parse a recipe file and handle imports recursively.
1804
+ """
1805
+ Parse a recipe file and handle imports recursively.
1630
1806
 
1631
1807
  This function now implements lazy variable evaluation: if root_task is provided,
1632
1808
  only variables reachable from that task will be evaluated. This provides significant
1633
1809
  performance and security benefits for recipes with many variables.
1634
1810
 
1635
1811
  Args:
1636
- recipe_path: Path to the main recipe file
1637
- project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
1638
- When using --tasks option, this should be the current working directory.
1639
- root_task: Optional root task for lazy variable evaluation. If provided, only variables
1640
- used by tasks reachable from root_task will be evaluated (optimization).
1641
- If None, all variables will be evaluated (for --list command compatibility).
1812
+ recipe_path: Path to the main recipe file
1813
+ project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
1814
+ When using --tasks option, this should be the current working directory.
1815
+ root_task: Optional root task for lazy variable evaluation. If provided, only variables
1816
+ used by tasks reachable from root_task will be evaluated (optimization).
1817
+ If None, all variables will be evaluated (for --list command compatibility).
1642
1818
 
1643
1819
  Returns:
1644
- Recipe object with all tasks (including recursively imported tasks) and evaluated variables
1820
+ Recipe object with all tasks (including recursively imported tasks) and evaluated variables
1645
1821
 
1646
1822
  Raises:
1647
- FileNotFoundError: If recipe file doesn't exist
1648
- CircularImportError: If circular imports are detected
1649
- yaml.YAMLError: If YAML is invalid
1650
- ValueError: If recipe structure is invalid
1823
+ FileNotFoundError: If recipe file doesn't exist
1824
+ CircularImportError: If circular imports are detected
1825
+ yaml.YAMLError: If YAML is invalid
1826
+ ValueError: If recipe structure is invalid
1827
+ @athena: 27326e37d5f3
1651
1828
  """
1652
1829
  if not recipe_path.exists():
1653
1830
  raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
@@ -1673,7 +1850,7 @@ def parse_recipe(
1673
1850
  raw_variables=raw_variables,
1674
1851
  evaluated_variables={}, # Empty initially
1675
1852
  _variables_evaluated=False,
1676
- _original_yaml_data=yaml_data
1853
+ _original_yaml_data=yaml_data,
1677
1854
  )
1678
1855
 
1679
1856
  # Trigger lazy variable evaluation
@@ -1690,21 +1867,23 @@ def _parse_file(
1690
1867
  project_root: Path,
1691
1868
  import_stack: list[Path] | None = None,
1692
1869
  ) -> dict[str, Task]:
1693
- """Parse a single YAML file and return tasks, recursively processing imports.
1870
+ """
1871
+ Parse a single YAML file and return tasks, recursively processing imports.
1694
1872
 
1695
1873
  Args:
1696
- file_path: Path to YAML file
1697
- namespace: Optional namespace prefix for tasks
1698
- project_root: Root directory of the project
1699
- import_stack: Stack of files being imported (for circular detection)
1874
+ file_path: Path to YAML file
1875
+ namespace: Optional namespace prefix for tasks
1876
+ project_root: Root directory of the project
1877
+ import_stack: Stack of files being imported (for circular detection)
1700
1878
 
1701
1879
  Returns:
1702
- Dictionary of task name to Task objects
1880
+ Dictionary of task name to Task objects
1703
1881
 
1704
1882
  Raises:
1705
- CircularImportError: If a circular import is detected
1706
- FileNotFoundError: If an imported file doesn't exist
1707
- ValueError: If task structure is invalid
1883
+ CircularImportError: If a circular import is detected
1884
+ FileNotFoundError: If an imported file doesn't exist
1885
+ ValueError: If task structure is invalid
1886
+ @athena: 8a903d791c2f
1708
1887
  """
1709
1888
  # Initialize import stack if not provided
1710
1889
  if import_stack is None:
@@ -1726,7 +1905,8 @@ def _parse_file(
1726
1905
  data = {}
1727
1906
 
1728
1907
  tasks: dict[str, Task] = {}
1729
- file_dir = file_path.parent
1908
+ # TODO: Understand why this is not used.
1909
+ # file_dir = file_path.parent
1730
1910
 
1731
1911
  # Default working directory is the project root (where tt is invoked)
1732
1912
  # NOT the directory where the tasks file is located
@@ -1746,7 +1926,9 @@ def _parse_file(
1746
1926
  local_import_namespaces.add(child_namespace)
1747
1927
 
1748
1928
  # Build full namespace chain
1749
- full_namespace = f"{namespace}.{child_namespace}" if namespace else child_namespace
1929
+ full_namespace = (
1930
+ f"{namespace}.{child_namespace}" if namespace else child_namespace
1931
+ )
1750
1932
 
1751
1933
  # Resolve import path relative to current file's directory
1752
1934
  child_path = file_path.parent / child_file
@@ -1764,15 +1946,16 @@ def _parse_file(
1764
1946
  tasks.update(nested_tasks)
1765
1947
 
1766
1948
  # Validate top-level keys (only imports, environments, tasks, and variables are allowed)
1767
- VALID_TOP_LEVEL_KEYS = {"imports", "environments", "tasks", "variables"}
1949
+ valid_top_level_keys = {"imports", "environments", "tasks", "variables"}
1768
1950
 
1769
1951
  # Check if tasks key is missing when there appear to be task definitions at root
1770
1952
  # Do this BEFORE checking for unknown keys, to provide better error message
1771
1953
  if "tasks" not in data and data:
1772
1954
  # Check if there are potential task definitions at root level
1773
1955
  potential_tasks = [
1774
- k for k, v in data.items()
1775
- if isinstance(v, dict) and k not in VALID_TOP_LEVEL_KEYS
1956
+ k
1957
+ for k, v in data.items()
1958
+ if isinstance(v, dict) and k not in valid_top_level_keys
1776
1959
  ]
1777
1960
 
1778
1961
  if potential_tasks:
@@ -1782,13 +1965,13 @@ def _parse_file(
1782
1965
  f"Found these keys at root level: {', '.join(potential_tasks)}\n\n"
1783
1966
  f"Did you mean:\n\n"
1784
1967
  f"tasks:\n"
1785
- + '\n'.join(f" {k}:" for k in potential_tasks) +
1786
- "\n cmd: ...\n\n"
1787
- f"Valid top-level keys are: {', '.join(sorted(VALID_TOP_LEVEL_KEYS))}"
1968
+ + "\n".join(f" {k}:" for k in potential_tasks)
1969
+ + "\n cmd: ...\n\n"
1970
+ f"Valid top-level keys are: {', '.join(sorted(valid_top_level_keys))}"
1788
1971
  )
1789
1972
 
1790
1973
  # Now check for other invalid top-level keys (non-dict values)
1791
- invalid_keys = set(data.keys()) - VALID_TOP_LEVEL_KEYS
1974
+ invalid_keys = set(data.keys()) - valid_top_level_keys
1792
1975
  if invalid_keys:
1793
1976
  raise ValueError(
1794
1977
  f"Invalid recipe format in {file_path}\n\n"
@@ -1806,10 +1989,14 @@ def _parse_file(
1806
1989
 
1807
1990
  # Process local tasks
1808
1991
  for task_name, task_data in tasks_data.items():
1809
-
1810
1992
  if not isinstance(task_data, dict):
1811
1993
  raise ValueError(f"Task '{task_name}' must be a dictionary")
1812
1994
 
1995
+ if "." in task_name:
1996
+ raise ValueError(
1997
+ f"Task name '{task_name}' cannot contain dots (reserved for namespacing)"
1998
+ )
1999
+
1813
2000
  if "cmd" not in task_data:
1814
2001
  raise ValueError(f"Task '{task_name}' missing required 'cmd' field")
1815
2002
 
@@ -1847,19 +2034,19 @@ def _parse_file(
1847
2034
  elif isinstance(dep, dict):
1848
2035
  # Dict dependency with args - rewrite the task name key
1849
2036
  rewritten_dep = {}
1850
- for task_name, args in dep.items():
1851
- if "." not in task_name:
2037
+ for t_name, args in dep.items():
2038
+ if "." not in t_name:
1852
2039
  # Simple name - prefix it
1853
- rewritten_dep[f"{namespace}.{task_name}"] = args
2040
+ rewritten_dep[f"{namespace}.{t_name}"] = args
1854
2041
  else:
1855
2042
  # Check if it starts with a local import namespace
1856
- dep_root = task_name.split(".", 1)[0]
2043
+ dep_root = t_name.split(".", 1)[0]
1857
2044
  if dep_root in local_import_namespaces:
1858
2045
  # Local import reference - prefix it
1859
- rewritten_dep[f"{namespace}.{task_name}"] = args
2046
+ rewritten_dep[f"{namespace}.{t_name}"] = args
1860
2047
  else:
1861
2048
  # External reference - keep as-is
1862
- rewritten_dep[task_name] = args
2049
+ rewritten_dep[t_name] = args
1863
2050
  rewritten_deps.append(rewritten_dep)
1864
2051
  else:
1865
2052
  # Unknown type - keep as-is
@@ -1893,15 +2080,17 @@ def _parse_file(
1893
2080
 
1894
2081
 
1895
2082
  def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> None:
1896
- """Check for exported arguments that differ only in case.
2083
+ """
2084
+ Check for exported arguments that differ only in case.
1897
2085
 
1898
2086
  On Unix systems, environment variables are case-sensitive, but having
1899
2087
  args that differ only in case (e.g., $Server and $server) can be confusing.
1900
2088
  This function emits a warning if such collisions are detected.
1901
2089
 
1902
2090
  Args:
1903
- args: List of argument specifications
1904
- task_name: Name of the task (for warning message)
2091
+ args: List of argument specifications
2092
+ task_name: Name of the task (for warning message)
2093
+ @athena: a3f0f3b184a8
1905
2094
  """
1906
2095
  import sys
1907
2096
 
@@ -1924,48 +2113,50 @@ def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> Non
1924
2113
  f"Warning: Task '{task_name}' has exported arguments that differ only in case: "
1925
2114
  f"${other_name} and ${name}. "
1926
2115
  f"This may be confusing on case-sensitive systems.",
1927
- file=sys.stderr
2116
+ file=sys.stderr,
1928
2117
  )
1929
2118
  else:
1930
2119
  seen_lower[lower_name] = name
1931
2120
 
1932
2121
 
1933
2122
  def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
1934
- """Parse argument specification from YAML.
2123
+ """
2124
+ Parse argument specification from YAML.
1935
2125
 
1936
2126
  Supports both string format and dictionary format:
1937
2127
 
1938
2128
  String format (simple names only):
1939
- - Simple name: "argname"
1940
- - Exported (becomes env var): "$argname"
2129
+ - Simple name: "argname"
2130
+ - Exported (becomes env var): "$argname"
1941
2131
 
1942
2132
  Dictionary format:
1943
- - argname: { default: "value" }
1944
- - argname: { type: int, default: 42 }
1945
- - argname: { type: int, min: 1, max: 100 }
1946
- - argname: { type: str, choices: ["dev", "staging", "prod"] }
1947
- - $argname: { default: "value" } # Exported (type not allowed)
2133
+ - argname: { default: "value" }
2134
+ - argname: { type: int, default: 42 }
2135
+ - argname: { type: int, min: 1, max: 100 }
2136
+ - argname: { type: str, choices: ["dev", "staging", "prod"] }
2137
+ - $argname: { default: "value" } # Exported (type not allowed)
1948
2138
 
1949
2139
  Args:
1950
- arg_spec: Argument specification (string or dict with single key)
2140
+ arg_spec: Argument specification (string or dict with single key)
1951
2141
 
1952
2142
  Returns:
1953
- ArgSpec object containing parsed argument information
2143
+ ArgSpec object containing parsed argument information
1954
2144
 
1955
2145
  Examples:
1956
- >>> parse_arg_spec("environment")
1957
- ArgSpec(name='environment', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=None)
1958
- >>> parse_arg_spec({"key2": {"default": "foo"}})
1959
- ArgSpec(name='key2', arg_type='str', default='foo', is_exported=False, min_val=None, max_val=None, choices=None)
1960
- >>> parse_arg_spec({"key3": {"type": "int", "default": 42}})
1961
- ArgSpec(name='key3', arg_type='int', default='42', is_exported=False, min_val=None, max_val=None, choices=None)
1962
- >>> parse_arg_spec({"replicas": {"type": "int", "min": 1, "max": 100}})
1963
- ArgSpec(name='replicas', arg_type='int', default=None, is_exported=False, min_val=1, max_val=100, choices=None)
1964
- >>> parse_arg_spec({"env": {"type": "str", "choices": ["dev", "prod"]}})
1965
- ArgSpec(name='env', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=['dev', 'prod'])
2146
+ >>> parse_arg_spec("environment")
2147
+ ArgSpec(name='environment', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=None)
2148
+ >>> parse_arg_spec({"key2": {"default": "foo"}})
2149
+ ArgSpec(name='key2', arg_type='str', default='foo', is_exported=False, min_val=None, max_val=None, choices=None)
2150
+ >>> parse_arg_spec({"key3": {"type": "int", "default": 42}})
2151
+ ArgSpec(name='key3', arg_type='int', default='42', is_exported=False, min_val=None, max_val=None, choices=None)
2152
+ >>> parse_arg_spec({"replicas": {"type": "int", "min": 1, "max": 100}})
2153
+ ArgSpec(name='replicas', arg_type='int', default=None, is_exported=False, min_val=1, max_val=100, choices=None)
2154
+ >>> parse_arg_spec({"env": {"type": "str", "choices": ["dev", "prod"]}})
2155
+ ArgSpec(name='env', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=['dev', 'prod'])
1966
2156
 
1967
2157
  Raises:
1968
- ValueError: If argument specification is invalid
2158
+ ValueError: If argument specification is invalid
2159
+ @athena: 2a4c7e804622
1969
2160
  """
1970
2161
  # Handle dictionary format: { argname: { type: ..., default: ... } }
1971
2162
  if isinstance(arg_spec, dict):
@@ -2023,23 +2214,25 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
2023
2214
  is_exported=is_exported,
2024
2215
  min_val=None,
2025
2216
  max_val=None,
2026
- choices=None
2217
+ choices=None,
2027
2218
  )
2028
2219
 
2029
2220
 
2030
2221
  def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
2031
- """Parse argument specification from dictionary format.
2222
+ """
2223
+ Parse argument specification from dictionary format.
2032
2224
 
2033
2225
  Args:
2034
- arg_name: Name of the argument
2035
- config: Dictionary with optional keys: type, default, min, max, choices
2036
- is_exported: Whether argument should be exported to environment
2226
+ arg_name: Name of the argument
2227
+ config: Dictionary with optional keys: type, default, min, max, choices
2228
+ is_exported: Whether argument should be exported to environment
2037
2229
 
2038
2230
  Returns:
2039
- ArgSpec object containing the parsed argument specification
2231
+ ArgSpec object containing the parsed argument specification
2040
2232
 
2041
2233
  Raises:
2042
- ValueError: If dictionary format is invalid
2234
+ ValueError: If dictionary format is invalid
2235
+ @athena: 5b6b93a3612a
2043
2236
  """
2044
2237
  # Validate dictionary keys
2045
2238
  valid_keys = {"type", "default", "min", "max", "choices"}
@@ -2073,22 +2266,18 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
2073
2266
  f"Exported argument '${arg_name}' must have a string default value.\n"
2074
2267
  f"Got: {default!r} (type: {type(default).__name__})\n"
2075
2268
  f"Exported arguments become environment variables, which are always strings.\n"
2076
- f"Use a quoted string: ${arg_name}: {{ default: \"{default}\" }}"
2269
+ f'Use a quoted string: ${arg_name}: {{ default: "{default}" }}'
2077
2270
  )
2078
2271
 
2079
2272
  # Validate choices
2080
2273
  if choices is not None:
2081
2274
  # Validate choices is a list
2082
2275
  if not isinstance(choices, list):
2083
- raise ValueError(
2084
- f"Argument '{arg_name}': choices must be a list"
2085
- )
2276
+ raise ValueError(f"Argument '{arg_name}': choices must be a list")
2086
2277
 
2087
2278
  # Validate choices is not empty
2088
2279
  if len(choices) == 0:
2089
- raise ValueError(
2090
- f"Argument '{arg_name}': choices list cannot be empty"
2091
- )
2280
+ raise ValueError(f"Argument '{arg_name}': choices list cannot be empty")
2092
2281
 
2093
2282
  # Check for mutual exclusivity with min/max
2094
2283
  if min_val is not None or max_val is not None:
@@ -2117,7 +2306,9 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
2117
2306
  for value_name, value_type in inferred_types[1:]:
2118
2307
  if value_type != first_type:
2119
2308
  # Build error message showing the conflicting types
2120
- type_info = ", ".join([f"{name}={vtype}" for name, vtype in inferred_types])
2309
+ type_info = ", ".join(
2310
+ [f"{name}={vtype}" for name, vtype in inferred_types]
2311
+ )
2121
2312
  raise ValueError(
2122
2313
  f"Argument '{arg_name}': inconsistent types inferred from min, max, and default.\n"
2123
2314
  f"All values must have the same type.\n"
@@ -2141,7 +2332,10 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
2141
2332
  )
2142
2333
 
2143
2334
  # Validate min/max are only used with numeric types
2144
- if (min_val is not None or max_val is not None) and arg_type not in ("int", "float"):
2335
+ if (min_val is not None or max_val is not None) and arg_type not in (
2336
+ "int",
2337
+ "float",
2338
+ ):
2145
2339
  raise ValueError(
2146
2340
  f"Argument '{arg_name}': min/max constraints are only supported for 'int' and 'float' types, "
2147
2341
  f"not '{arg_type}'"
@@ -2270,12 +2464,15 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
2270
2464
  is_exported=is_exported,
2271
2465
  min_val=min_val,
2272
2466
  max_val=max_val,
2273
- choices=choices
2467
+ choices=choices,
2274
2468
  )
2275
2469
 
2276
2470
 
2277
- def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> DependencyInvocation:
2278
- """Parse a dependency specification into a DependencyInvocation.
2471
+ def parse_dependency_spec(
2472
+ dep_spec: str | dict[str, Any], recipe: Recipe
2473
+ ) -> DependencyInvocation:
2474
+ """
2475
+ Parse a dependency specification into a DependencyInvocation.
2279
2476
 
2280
2477
  Supports three forms:
2281
2478
  1. Simple string: "task_name" -> DependencyInvocation(task_name, None)
@@ -2283,14 +2480,15 @@ def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> Dep
2283
2480
  3. Named args: {"task_name": {arg1: val1}} -> DependencyInvocation(task_name, {arg1: val1})
2284
2481
 
2285
2482
  Args:
2286
- dep_spec: Dependency specification (string or dict)
2287
- recipe: Recipe containing task definitions (for arg normalization)
2483
+ dep_spec: Dependency specification (string or dict)
2484
+ recipe: Recipe containing task definitions (for arg normalization)
2288
2485
 
2289
2486
  Returns:
2290
- DependencyInvocation object with normalized args
2487
+ DependencyInvocation object with normalized args
2291
2488
 
2292
2489
  Raises:
2293
- ValueError: If dependency specification is invalid
2490
+ ValueError: If dependency specification is invalid
2491
+ @athena: d30ff06259c2
2294
2492
  """
2295
2493
  # Simple string case
2296
2494
  if isinstance(dep_spec, str):
@@ -2339,17 +2537,19 @@ def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> Dep
2339
2537
 
2340
2538
 
2341
2539
  def _get_validated_task(task_name: str, recipe: Recipe) -> Task:
2342
- """Get and validate that a task exists in the recipe.
2540
+ """
2541
+ Get and validate that a task exists in the recipe.
2343
2542
 
2344
2543
  Args:
2345
- task_name: Name of the task to retrieve
2346
- recipe: Recipe containing task definitions
2544
+ task_name: Name of the task to retrieve
2545
+ recipe: Recipe containing task definitions
2347
2546
 
2348
2547
  Returns:
2349
- The validated Task object
2548
+ The validated Task object
2350
2549
 
2351
2550
  Raises:
2352
- ValueError: If task is not found
2551
+ ValueError: If task is not found
2552
+ @athena: 674f077e3977
2353
2553
  """
2354
2554
  task = recipe.get_task(task_name)
2355
2555
  if task is None:
@@ -2360,18 +2560,20 @@ def _get_validated_task(task_name: str, recipe: Recipe) -> Task:
2360
2560
  def _parse_positional_dependency_args(
2361
2561
  task_name: str, args_list: list[Any], recipe: Recipe
2362
2562
  ) -> DependencyInvocation:
2363
- """Parse positional dependency arguments.
2563
+ """
2564
+ Parse positional dependency arguments.
2364
2565
 
2365
2566
  Args:
2366
- task_name: Name of the dependency task
2367
- args_list: List of positional argument values
2368
- recipe: Recipe containing task definitions
2567
+ task_name: Name of the dependency task
2568
+ args_list: List of positional argument values
2569
+ recipe: Recipe containing task definitions
2369
2570
 
2370
2571
  Returns:
2371
- DependencyInvocation with normalized named args
2572
+ DependencyInvocation with normalized named args
2372
2573
 
2373
2574
  Raises:
2374
- ValueError: If validation fails
2575
+ ValueError: If validation fails
2576
+ @athena: 4d1c7957e2dd
2375
2577
  """
2376
2578
  # Get the task to validate against
2377
2579
  task = _get_validated_task(task_name, recipe)
@@ -2396,7 +2598,9 @@ def _parse_positional_dependency_args(
2396
2598
  spec = parsed_specs[i]
2397
2599
  if isinstance(value, str):
2398
2600
  # Convert string values using type validator
2399
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
2601
+ click_type = get_click_type(
2602
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
2603
+ )
2400
2604
  args_dict[spec.name] = click_type.convert(value, None, None)
2401
2605
  else:
2402
2606
  # Value is already typed (e.g., bool, int from YAML)
@@ -2407,7 +2611,9 @@ def _parse_positional_dependency_args(
2407
2611
  spec = parsed_specs[i]
2408
2612
  if spec.default is not None:
2409
2613
  # Defaults in task specs are always strings, convert them
2410
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
2614
+ click_type = get_click_type(
2615
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
2616
+ )
2411
2617
  args_dict[spec.name] = click_type.convert(spec.default, None, None)
2412
2618
  else:
2413
2619
  raise ValueError(
@@ -2420,18 +2626,20 @@ def _parse_positional_dependency_args(
2420
2626
  def _parse_named_dependency_args(
2421
2627
  task_name: str, args_dict: dict[str, Any], recipe: Recipe
2422
2628
  ) -> DependencyInvocation:
2423
- """Parse named dependency arguments.
2629
+ """
2630
+ Parse named dependency arguments.
2424
2631
 
2425
2632
  Args:
2426
- task_name: Name of the dependency task
2427
- args_dict: Dictionary of argument names to values
2428
- recipe: Recipe containing task definitions
2633
+ task_name: Name of the dependency task
2634
+ args_dict: Dictionary of argument names to values
2635
+ recipe: Recipe containing task definitions
2429
2636
 
2430
2637
  Returns:
2431
- DependencyInvocation with normalized args (defaults filled)
2638
+ DependencyInvocation with normalized args (defaults filled)
2432
2639
 
2433
2640
  Raises:
2434
- ValueError: If validation fails
2641
+ ValueError: If validation fails
2642
+ @athena: c522211de525
2435
2643
  """
2436
2644
  # Get the task to validate against
2437
2645
  task = _get_validated_task(task_name, recipe)
@@ -2450,9 +2658,7 @@ def _parse_named_dependency_args(
2450
2658
  # Validate all provided arg names exist
2451
2659
  for arg_name in args_dict:
2452
2660
  if arg_name not in spec_map:
2453
- raise ValueError(
2454
- f"Task '{task_name}' has no argument named '{arg_name}'"
2455
- )
2661
+ raise ValueError(f"Task '{task_name}' has no argument named '{arg_name}'")
2456
2662
 
2457
2663
  # Build normalized args dict with defaults
2458
2664
  normalized_args = {}
@@ -2461,14 +2667,18 @@ def _parse_named_dependency_args(
2461
2667
  # Use provided value with type conversion (only convert strings)
2462
2668
  value = args_dict[spec.name]
2463
2669
  if isinstance(value, str):
2464
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
2670
+ click_type = get_click_type(
2671
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
2672
+ )
2465
2673
  normalized_args[spec.name] = click_type.convert(value, None, None)
2466
2674
  else:
2467
2675
  # Value is already typed (e.g., bool, int from YAML)
2468
2676
  normalized_args[spec.name] = value
2469
2677
  elif spec.default is not None:
2470
2678
  # Use default value (defaults are always strings in task specs)
2471
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
2679
+ click_type = get_click_type(
2680
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
2681
+ )
2472
2682
  normalized_args[spec.name] = click_type.convert(spec.default, None, None)
2473
2683
  else:
2474
2684
  # Required arg not provided