tasktree 0.0.10__tar.gz → 0.0.12__tar.gz

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.
Files changed (73) hide show
  1. {tasktree-0.0.10 → tasktree-0.0.12}/PKG-INFO +10 -5
  2. {tasktree-0.0.10 → tasktree-0.0.12}/README.md +9 -4
  3. {tasktree-0.0.10 → tasktree-0.0.12}/pyproject.toml +1 -1
  4. {tasktree-0.0.10 → tasktree-0.0.12}/schema/tasktree-schema.json +66 -1
  5. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/cli.py +5 -3
  6. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/parser.py +65 -46
  7. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_end_to_end.py +5 -1
  8. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_exported_args.py +24 -6
  9. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_parameterized_dependencies.yaml +2 -2
  10. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_parameterized_deps_execution.py +4 -4
  11. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_variables.py +232 -0
  12. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_cli.py +4 -4
  13. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_dependency_parsing.py +1 -1
  14. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_list_formatting.py +9 -9
  15. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_parameterized_graph.py +1 -1
  16. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_parser.py +38 -43
  17. {tasktree-0.0.10 → tasktree-0.0.12}/.claude/settings.local.json +0 -0
  18. {tasktree-0.0.10 → tasktree-0.0.12}/.github/workflows/claude-code-review.yml +0 -0
  19. {tasktree-0.0.10 → tasktree-0.0.12}/.github/workflows/claude.yml +0 -0
  20. {tasktree-0.0.10 → tasktree-0.0.12}/.github/workflows/release.yml +0 -0
  21. {tasktree-0.0.10 → tasktree-0.0.12}/.github/workflows/test.yml +0 -0
  22. {tasktree-0.0.10 → tasktree-0.0.12}/.gitignore +0 -0
  23. {tasktree-0.0.10 → tasktree-0.0.12}/.python-version +0 -0
  24. {tasktree-0.0.10 → tasktree-0.0.12}/CLAUDE.md +0 -0
  25. {tasktree-0.0.10 → tasktree-0.0.12}/example/source.txt +0 -0
  26. {tasktree-0.0.10 → tasktree-0.0.12}/example/tasktree.yaml +0 -0
  27. {tasktree-0.0.10 → tasktree-0.0.12}/requirements/implemented/01-basic-variables.md +0 -0
  28. {tasktree-0.0.10 → tasktree-0.0.12}/requirements/implemented/02-env-variable-type.md +0 -0
  29. {tasktree-0.0.10 → tasktree-0.0.12}/requirements/implemented/03-direct-env-substitution.md +0 -0
  30. {tasktree-0.0.10 → tasktree-0.0.12}/requirements/implemented/04-file-read-variables.md +0 -0
  31. {tasktree-0.0.10 → tasktree-0.0.12}/requirements/implemented/bug-report-dependency-triggering.md +0 -0
  32. {tasktree-0.0.10 → tasktree-0.0.12}/requirements/implemented/docker-task-environments.md +0 -0
  33. {tasktree-0.0.10 → tasktree-0.0.12}/requirements/implemented/shell-environment-requirements.md +0 -0
  34. {tasktree-0.0.10 → tasktree-0.0.12}/schema/README.md +0 -0
  35. {tasktree-0.0.10 → tasktree-0.0.12}/schema/vscode-settings-snippet.json +0 -0
  36. {tasktree-0.0.10 → tasktree-0.0.12}/src/__init__.py +0 -0
  37. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/__init__.py +0 -0
  38. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/docker.py +0 -0
  39. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/executor.py +0 -0
  40. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/graph.py +0 -0
  41. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/hasher.py +0 -0
  42. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/state.py +0 -0
  43. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/substitution.py +0 -0
  44. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/tasks.py +0 -0
  45. {tasktree-0.0.10 → tasktree-0.0.12}/src/tasktree/types.py +0 -0
  46. {tasktree-0.0.10 → tasktree-0.0.12}/tasktree.yaml +0 -0
  47. {tasktree-0.0.10 → tasktree-0.0.12}/tests/e2e/__init__.py +0 -0
  48. {tasktree-0.0.10 → tasktree-0.0.12}/tests/e2e/test_docker_basic.py +0 -0
  49. {tasktree-0.0.10 → tasktree-0.0.12}/tests/e2e/test_docker_environment.py +0 -0
  50. {tasktree-0.0.10 → tasktree-0.0.12}/tests/e2e/test_docker_ownership.py +0 -0
  51. {tasktree-0.0.10 → tasktree-0.0.12}/tests/e2e/test_docker_volumes.py +0 -0
  52. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_arg_choices.py +0 -0
  53. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_arg_min_max.py +0 -0
  54. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_builtin_variables.py +0 -0
  55. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_clean_state.py +0 -0
  56. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_cli_options.py +0 -0
  57. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_dependency_execution.py +0 -0
  58. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_docker_build_args.py +0 -0
  59. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_input_detection.py +0 -0
  60. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_missing_outputs.py +0 -0
  61. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_nested_imports.py +0 -0
  62. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_state_persistence.py +0 -0
  63. {tasktree-0.0.10 → tasktree-0.0.12}/tests/integration/test_working_directory.py +0 -0
  64. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_docker.py +0 -0
  65. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_environment_tracking.py +0 -0
  66. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_executor.py +0 -0
  67. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_graph.py +0 -0
  68. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_hasher.py +0 -0
  69. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_state.py +0 -0
  70. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_substitution.py +0 -0
  71. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_tasks.py +0 -0
  72. {tasktree-0.0.10 → tasktree-0.0.12}/tests/unit/test_types.py +0 -0
  73. {tasktree-0.0.10 → tasktree-0.0.12}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree
3
- Version: 0.0.10
3
+ Version: 0.0.12
4
4
  Summary: A task automation tool with incremental execution
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: click>=8.1.0
@@ -604,7 +604,9 @@ Dependencies can invoke tasks with specific arguments, enabling flexible and reu
604
604
  tasks:
605
605
  # Task with parameters
606
606
  process:
607
- args: [mode, verbose=false]
607
+ args:
608
+ - mode
609
+ - verbose: { default: false }
608
610
  cmd: echo "mode={{arg.mode}} verbose={{arg.verbose}}"
609
611
 
610
612
  # Simple dependency (uses defaults)
@@ -716,10 +718,13 @@ For more complex scenarios, define environment variables in the `variables` sect
716
718
 
717
719
  ```yaml
718
720
  variables:
719
- # Direct env reference (resolved at parse time)
721
+ # Required env var (error if not set)
720
722
  api_key: { env: API_KEY }
721
- db_host: { env: DATABASE_HOST }
722
-
723
+
724
+ # Optional env var with default
725
+ port: { env: PORT, default: "8080" }
726
+ log_level: { env: LOG_LEVEL, default: "info" }
727
+
723
728
  # Or using string substitution
724
729
  deploy_user: "{{ env.DEPLOY_USER }}"
725
730
 
@@ -589,7 +589,9 @@ Dependencies can invoke tasks with specific arguments, enabling flexible and reu
589
589
  tasks:
590
590
  # Task with parameters
591
591
  process:
592
- args: [mode, verbose=false]
592
+ args:
593
+ - mode
594
+ - verbose: { default: false }
593
595
  cmd: echo "mode={{arg.mode}} verbose={{arg.verbose}}"
594
596
 
595
597
  # Simple dependency (uses defaults)
@@ -701,10 +703,13 @@ For more complex scenarios, define environment variables in the `variables` sect
701
703
 
702
704
  ```yaml
703
705
  variables:
704
- # Direct env reference (resolved at parse time)
706
+ # Required env var (error if not set)
705
707
  api_key: { env: API_KEY }
706
- db_host: { env: DATABASE_HOST }
707
-
708
+
709
+ # Optional env var with default
710
+ port: { env: PORT, default: "8080" }
711
+ log_level: { env: LOG_LEVEL, default: "info" }
712
+
708
713
  # Or using string substitution
709
714
  deploy_user: "{{ env.DEPLOY_USER }}"
710
715
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tasktree"
3
- version = "0.0.10"
3
+ version = "0.0.12"
4
4
  description = "A task automation tool with incremental execution"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -99,6 +99,70 @@
99
99
  },
100
100
  "additionalProperties": false
101
101
  },
102
+ "variables": {
103
+ "description": "Variable definitions that can be referenced in tasks using {{ var.name }} syntax",
104
+ "type": "object",
105
+ "patternProperties": {
106
+ "^[a-zA-Z][a-zA-Z0-9_-]*$": {
107
+ "oneOf": [
108
+ {
109
+ "type": "string",
110
+ "description": "Simple string value"
111
+ },
112
+ {
113
+ "type": "integer",
114
+ "description": "Integer value"
115
+ },
116
+ {
117
+ "type": "number",
118
+ "description": "Floating-point number value"
119
+ },
120
+ {
121
+ "type": "boolean",
122
+ "description": "Boolean value (true/false)"
123
+ },
124
+ {
125
+ "type": "object",
126
+ "description": "Environment variable reference",
127
+ "properties": {
128
+ "env": {
129
+ "type": "string",
130
+ "description": "Name of environment variable to read",
131
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
132
+ }
133
+ },
134
+ "required": ["env"],
135
+ "additionalProperties": false
136
+ },
137
+ {
138
+ "type": "object",
139
+ "description": "File read reference",
140
+ "properties": {
141
+ "read": {
142
+ "type": "string",
143
+ "description": "Path to file to read (relative to recipe file, or absolute)"
144
+ }
145
+ },
146
+ "required": ["read"],
147
+ "additionalProperties": false
148
+ },
149
+ {
150
+ "type": "object",
151
+ "description": "Eval command reference (executes shell command and captures output)",
152
+ "properties": {
153
+ "eval": {
154
+ "type": "string",
155
+ "description": "Shell command to execute (stdout becomes variable value)"
156
+ }
157
+ },
158
+ "required": ["eval"],
159
+ "additionalProperties": false
160
+ }
161
+ ]
162
+ }
163
+ },
164
+ "additionalProperties": false
165
+ },
102
166
  "tasks": {
103
167
  "description": "Task definitions",
104
168
  "type": "object",
@@ -190,6 +254,7 @@
190
254
  "anyOf": [
191
255
  { "required": ["tasks"] },
192
256
  { "required": ["imports"] },
193
- { "required": ["environments"] }
257
+ { "required": ["environments"] },
258
+ { "required": ["variables"] }
194
259
  ]
195
260
  }
@@ -192,10 +192,12 @@ tasks:
192
192
  # deploy:
193
193
  # desc: Deploy to environment
194
194
  # deps: [build]
195
- # args: [environment, region=eu-west-1]
195
+ # args:
196
+ # - environment
197
+ # - region: { default: eu-west-1 }
196
198
  # cmd: |
197
- # echo "Deploying to {{environment}} in {{region}}"
198
- # ./deploy.sh {{environment}} {{region}}
199
+ # echo "Deploying to {{ arg.environment }} in {{ arg.region }}"
200
+ # ./deploy.sh {{ arg.environment }} {{ arg.region }}
199
201
 
200
202
  # Uncomment and modify the examples above to define your tasks
201
203
  """
@@ -287,26 +287,35 @@ def _is_env_variable_reference(value: Any) -> bool:
287
287
  return isinstance(value, dict) and "env" in value
288
288
 
289
289
 
290
- def _validate_env_variable_reference(var_name: str, value: dict) -> str:
291
- """Validate and extract environment variable name from reference.
290
+ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, str | None]:
291
+ """Validate and extract environment variable name and optional default from reference.
292
292
 
293
293
  Args:
294
294
  var_name: Name of the variable being defined
295
- value: Dict that should be { env: ENV_VAR_NAME }
295
+ value: Dict that should be { env: ENV_VAR_NAME } or { env: ENV_VAR_NAME, default: "value" }
296
296
 
297
297
  Returns:
298
- Environment variable name
298
+ Tuple of (environment variable name, default value or None)
299
299
 
300
300
  Raises:
301
301
  ValueError: If reference is invalid
302
302
  """
303
- # Validate dict structure
304
- if len(value) != 1:
305
- extra_keys = [k for k in value.keys() if k != "env"]
303
+ # Validate dict structure - allow 'env' and optionally 'default'
304
+ valid_keys = {"env", "default"}
305
+ invalid_keys = set(value.keys()) - valid_keys
306
+ if invalid_keys:
306
307
  raise ValueError(
307
308
  f"Invalid environment variable reference in variable '{var_name}'.\n"
308
- f"Expected: {{ env: VARIABLE_NAME }}\n"
309
- f"Found extra keys: {', '.join(extra_keys)}"
309
+ f"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}\n"
310
+ f"Found invalid keys: {', '.join(invalid_keys)}"
311
+ )
312
+
313
+ # Validate 'env' key is present
314
+ if "env" not in value:
315
+ raise ValueError(
316
+ f"Invalid environment variable reference in variable '{var_name}'.\n"
317
+ f"Missing required 'env' key.\n"
318
+ f"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}"
310
319
  )
311
320
 
312
321
  env_var_name = value["env"]
@@ -315,7 +324,7 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> str:
315
324
  if not env_var_name or not isinstance(env_var_name, str):
316
325
  raise ValueError(
317
326
  f"Invalid environment variable reference in variable '{var_name}'.\n"
318
- f"Expected: {{ env: VARIABLE_NAME }}\n"
327
+ f"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}"
319
328
  f"Found: {{ env: {env_var_name!r} }}"
320
329
  )
321
330
 
@@ -327,23 +336,36 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> str:
327
336
  f"and contain only alphanumerics and underscores."
328
337
  )
329
338
 
330
- return env_var_name
339
+ # Extract and validate default if present
340
+ default = value.get("default")
341
+ if default is not None:
342
+ # Default must be a string (env vars are always strings)
343
+ if not isinstance(default, str):
344
+ raise ValueError(
345
+ f"Invalid default value in variable '{var_name}'.\n"
346
+ f"Environment variable defaults must be strings.\n"
347
+ f"Got: {default!r} (type: {type(default).__name__})\n"
348
+ f"Use a quoted string: {{ env: {env_var_name}, default: \"{default}\" }}"
349
+ )
350
+
351
+ return env_var_name, default
331
352
 
332
353
 
333
- def _resolve_env_variable(var_name: str, env_var_name: str) -> str:
354
+ def _resolve_env_variable(var_name: str, env_var_name: str, default: str | None = None) -> str:
334
355
  """Resolve environment variable value.
335
356
 
336
357
  Args:
337
358
  var_name: Name of the variable being defined
338
359
  env_var_name: Name of environment variable to read
360
+ default: Optional default value to use if environment variable is not set
339
361
 
340
362
  Returns:
341
- Environment variable value as string
363
+ Environment variable value as string, or default if not set and default provided
342
364
 
343
365
  Raises:
344
- ValueError: If environment variable is not set
366
+ ValueError: If environment variable is not set and no default provided
345
367
  """
346
- value = os.environ.get(env_var_name)
368
+ value = os.environ.get(env_var_name, default)
347
369
 
348
370
  if value is None:
349
371
  raise ValueError(
@@ -730,11 +752,11 @@ def _resolve_variable_value(
730
752
 
731
753
  # Check if this is an environment variable reference
732
754
  if _is_env_variable_reference(raw_value):
733
- # Validate and extract env var name
734
- env_var_name = _validate_env_variable_reference(name, raw_value)
755
+ # Validate and extract env var name and optional default
756
+ env_var_name, default = _validate_env_variable_reference(name, raw_value)
735
757
 
736
- # Resolve from os.environ
737
- string_value = _resolve_env_variable(name, env_var_name)
758
+ # Resolve from os.environ (with optional default)
759
+ string_value = _resolve_env_variable(name, env_var_name, default)
738
760
 
739
761
  # Still perform variable-in-variable substitution
740
762
  from tasktree.substitution import substitute_variables
@@ -1259,18 +1281,16 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
1259
1281
 
1260
1282
  Supports both string format and dictionary format:
1261
1283
 
1262
- String format:
1284
+ String format (simple names only):
1263
1285
  - Simple name: "argname"
1264
1286
  - Exported (becomes env var): "$argname"
1265
- - With default: "argname=value" or "$argname=value"
1266
- - Legacy type syntax: "argname:type=value" (for backwards compat)
1267
1287
 
1268
1288
  Dictionary format:
1269
1289
  - argname: { default: "value" }
1270
1290
  - argname: { type: int, default: 42 }
1271
1291
  - argname: { type: int, min: 1, max: 100 }
1272
1292
  - argname: { type: str, choices: ["dev", "staging", "prod"] }
1273
- - $argname: { default: "value" } # Exported
1293
+ - $argname: { default: "value" } # Exported (type not allowed)
1274
1294
 
1275
1295
  Args:
1276
1296
  arg_spec: Argument specification (string or dict with single key)
@@ -1328,34 +1348,24 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
1328
1348
  if is_exported:
1329
1349
  arg_spec = arg_spec[1:] # Remove $ prefix
1330
1350
 
1331
- # Split on = to separate name:type from default
1332
- if "=" in arg_spec:
1333
- name_type, default = arg_spec.split("=", 1)
1334
- else:
1335
- name_type = arg_spec
1336
- default = None
1351
+ # String format only supports simple names (no = or :)
1352
+ if "=" in arg_spec or ":" in arg_spec:
1353
+ raise ValueError(
1354
+ f"Invalid argument syntax: {'$' if is_exported else ''}{arg_spec}\n\n"
1355
+ f"String format only supports simple argument names.\n"
1356
+ f"Use YAML dict format for type annotations, defaults, or constraints:\n"
1357
+ f" args:\n"
1358
+ f" - {'$' if is_exported else ''}{arg_spec.split('=')[0].split(':')[0]}: {{ default: value }}"
1359
+ )
1337
1360
 
1338
- # Split on : to separate name from type
1339
- if ":" in name_type:
1340
- name, arg_type = name_type.split(":", 1)
1361
+ name = arg_spec
1362
+ arg_type = "str"
1341
1363
 
1342
- # Exported arguments cannot have type annotations
1343
- if is_exported:
1344
- raise ValueError(
1345
- f"Type annotations not allowed on exported arguments\n"
1346
- f"In argument: ${name}:{arg_type}\n\n"
1347
- f"Exported arguments are always strings. Remove the type annotation:\n"
1348
- f" args: [${name}]"
1349
- )
1350
- else:
1351
- name = name_type
1352
- arg_type = "str"
1353
-
1354
- # String format doesn't support min/max/choices
1364
+ # String format doesn't support min/max/choices/defaults
1355
1365
  return ArgSpec(
1356
1366
  name=name,
1357
1367
  arg_type=arg_type,
1358
- default=default,
1368
+ default=None,
1359
1369
  is_exported=is_exported,
1360
1370
  min_val=None,
1361
1371
  max_val=None,
@@ -1403,6 +1413,15 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
1403
1413
  f"Exported arguments are always strings. Remove the 'type' field"
1404
1414
  )
1405
1415
 
1416
+ # Exported arguments must have string defaults (if any default is provided)
1417
+ if is_exported and default is not None and not isinstance(default, str):
1418
+ raise ValueError(
1419
+ f"Exported argument '${arg_name}' must have a string default value.\n"
1420
+ f"Got: {default!r} (type: {type(default).__name__})\n"
1421
+ f"Exported arguments become environment variables, which are always strings.\n"
1422
+ f"Use a quoted string: ${arg_name}: {{ default: \"{default}\" }}"
1423
+ )
1424
+
1406
1425
  # Validate choices
1407
1426
  if choices is not None:
1408
1427
  # Validate choices is a list
@@ -35,7 +35,11 @@ class TestEndToEnd(unittest.TestCase):
35
35
  recipe_file.write_text("""
36
36
  tasks:
37
37
  deploy:
38
- args: [environment, region:str=us-west-1, port:int=8080, debug:bool=false]
38
+ args:
39
+ - environment
40
+ - region: { type: str, default: us-west-1 }
41
+ - port: { type: int, default: 8080 }
42
+ - debug: { type: bool, default: false }
39
43
  outputs: [deploy.log]
40
44
  cmd: echo "env={{ arg.environment }} region={{ arg.region }} port={{ arg.port }} debug={{ arg.debug }}" > deploy.log
41
45
  """)
@@ -28,7 +28,9 @@ class TestExportedArgs(unittest.TestCase):
28
28
  f"""
29
29
  tasks:
30
30
  test:
31
- args: [$server, $user=admin]
31
+ args:
32
+ - $server
33
+ - $user: {{ default: admin }}
32
34
  cmd: {env_check}
33
35
  """
34
36
  )
@@ -59,7 +61,9 @@ tasks:
59
61
  f"""
60
62
  tasks:
61
63
  test:
62
- args: [$server, $port=8080]
64
+ args:
65
+ - $server
66
+ - $port: {{ default: "8080" }}
63
67
  cmd: {env_check}
64
68
  """
65
69
  )
@@ -94,7 +98,9 @@ tasks:
94
98
  f"""
95
99
  tasks:
96
100
  test:
97
- args: [$server, $port=8080]
101
+ args:
102
+ - $server
103
+ - $port: {{ default: "8080" }}
98
104
  cmd: {env_check}
99
105
  """
100
106
  )
@@ -164,7 +170,9 @@ tasks:
164
170
  f"""
165
171
  tasks:
166
172
  deploy:
167
- args: [$server, port=8080]
173
+ args:
174
+ - $server
175
+ - port: {{ default: 8080 }}
168
176
  cmd: {cmd_line}
169
177
  """
170
178
  )
@@ -242,12 +250,22 @@ tasks:
242
250
  """Test that exported arguments with type annotations fail during arg parsing."""
243
251
  from tasktree.parser import parse_arg_spec
244
252
 
245
- # Test that parse_arg_spec raises error for exported args with types
253
+ # Test that parse_arg_spec raises error for exported args with types (old colon syntax)
246
254
  with self.assertRaises(ValueError) as cm:
247
255
  parse_arg_spec("$server:str")
248
256
 
257
+ self.assertIn("Invalid argument syntax", str(cm.exception))
258
+
259
+ def test_exported_arg_yaml_dict_with_type_fails(self):
260
+ """Test that exported arguments with type field in YAML dict format fails."""
261
+ from tasktree.parser import parse_arg_spec
262
+
263
+ # Test that parse_arg_spec raises error for exported args with type in dict format
264
+ with self.assertRaises(ValueError) as cm:
265
+ parse_arg_spec({"$server": {"type": "str"}})
266
+
249
267
  self.assertIn("Type annotations not allowed", str(cm.exception))
250
- self.assertIn("$server:str", str(cm.exception))
268
+ self.assertIn("$server", str(cm.exception))
251
269
 
252
270
  def test_multiline_command_with_exported_args(self):
253
271
  """Test exported args work with multi-line commands."""
@@ -1,8 +1,8 @@
1
1
  tasks:
2
2
  task_parameterized:
3
3
  args:
4
- - mode=debug
5
- - verbose=false
4
+ - mode: { default: "debug" }
5
+ - verbose: { default: "false" }
6
6
  cmd: echo "mode={{arg.mode}} verbose={{arg.verbose}}"
7
7
 
8
8
  task_consumer_implicit:
@@ -37,8 +37,8 @@ class TestParameterizedDependencyExecution(unittest.TestCase):
37
37
  tasks:
38
38
  build:
39
39
  args:
40
- - mode=debug
41
- - optimize:bool=false
40
+ - mode: { default: "debug" }
41
+ - optimize: { type: bool, default: false }
42
42
  outputs:
43
43
  - "build-{{arg.mode}}.log"
44
44
  cmd: echo "Building {{arg.mode}} optimize={{arg.optimize}}" > build-{{arg.mode}}.log
@@ -151,8 +151,8 @@ tasks:
151
151
  tasks:
152
152
  generate:
153
153
  args:
154
- - format=json
155
- - pretty:bool=false
154
+ - format: { default: "json" }
155
+ - pretty: { type: bool, default: false }
156
156
  outputs:
157
157
  - "data.{{arg.format}}"
158
158
  cmd: echo "{{arg.format}},pretty={{arg.pretty}}" > data.{{arg.format}}
@@ -693,6 +693,238 @@ tasks:
693
693
  finally:
694
694
  os.chdir(original_cwd)
695
695
 
696
+ def test_env_variable_with_default_when_not_set(self):
697
+ """Test default value is used when environment variable is not set."""
698
+ # Ensure env var is NOT set
699
+ if "TEST_PORT_DEFAULT" in os.environ:
700
+ del os.environ["TEST_PORT_DEFAULT"]
701
+
702
+ with TemporaryDirectory() as tmpdir:
703
+ project_root = Path(tmpdir)
704
+
705
+ recipe_file = project_root / "tasktree.yaml"
706
+ recipe_file.write_text("""
707
+ variables:
708
+ port: { env: TEST_PORT_DEFAULT, default: "8080" }
709
+
710
+ tasks:
711
+ test:
712
+ outputs: [config.txt]
713
+ cmd: echo "Port={{ var.port }}" > config.txt
714
+ """)
715
+
716
+ original_cwd = os.getcwd()
717
+ try:
718
+ os.chdir(project_root)
719
+
720
+ # Run task
721
+ result = self.runner.invoke(app, ["test"], env=self.env)
722
+ self.assertEqual(result.exit_code, 0)
723
+
724
+ # Verify default value was used
725
+ output_file = project_root / "config.txt"
726
+ self.assertTrue(output_file.exists())
727
+ content = output_file.read_text().strip()
728
+ self.assertEqual(content, "Port=8080")
729
+
730
+ finally:
731
+ os.chdir(original_cwd)
732
+
733
+ def test_env_variable_with_default_when_set(self):
734
+ """Test environment variable value is used when set, ignoring default."""
735
+ # Set environment variable
736
+ os.environ["TEST_PORT_OVERRIDE"] = "9000"
737
+
738
+ try:
739
+ with TemporaryDirectory() as tmpdir:
740
+ project_root = Path(tmpdir)
741
+
742
+ recipe_file = project_root / "tasktree.yaml"
743
+ recipe_file.write_text("""
744
+ variables:
745
+ port: { env: TEST_PORT_OVERRIDE, default: "8080" }
746
+
747
+ tasks:
748
+ test:
749
+ outputs: [config.txt]
750
+ cmd: echo "Port={{ var.port }}" > config.txt
751
+ """)
752
+
753
+ original_cwd = os.getcwd()
754
+ try:
755
+ os.chdir(project_root)
756
+
757
+ # Run task
758
+ result = self.runner.invoke(app, ["test"], env=self.env)
759
+ self.assertEqual(result.exit_code, 0)
760
+
761
+ # Verify env var value was used, not default
762
+ output_file = project_root / "config.txt"
763
+ self.assertTrue(output_file.exists())
764
+ content = output_file.read_text().strip()
765
+ self.assertEqual(content, "Port=9000")
766
+
767
+ finally:
768
+ os.chdir(original_cwd)
769
+
770
+ finally:
771
+ del os.environ["TEST_PORT_OVERRIDE"]
772
+
773
+ def test_env_variable_empty_string_vs_default(self):
774
+ """Test that empty string env var is used, not the default."""
775
+ # Set environment variable to empty string
776
+ os.environ["TEST_EMPTY_VAR"] = ""
777
+
778
+ try:
779
+ with TemporaryDirectory() as tmpdir:
780
+ project_root = Path(tmpdir)
781
+
782
+ recipe_file = project_root / "tasktree.yaml"
783
+ recipe_file.write_text("""
784
+ variables:
785
+ value: { env: TEST_EMPTY_VAR, default: "default_value" }
786
+
787
+ tasks:
788
+ test:
789
+ outputs: [output.txt]
790
+ cmd: echo "Value=[{{ var.value }}]" > output.txt
791
+ """)
792
+
793
+ original_cwd = os.getcwd()
794
+ try:
795
+ os.chdir(project_root)
796
+
797
+ # Run task
798
+ result = self.runner.invoke(app, ["test"], env=self.env)
799
+ self.assertEqual(result.exit_code, 0)
800
+
801
+ # Verify empty string was used (not default)
802
+ output_file = project_root / "output.txt"
803
+ self.assertTrue(output_file.exists())
804
+ content = output_file.read_text().strip()
805
+ self.assertEqual(content, "Value=[]")
806
+
807
+ finally:
808
+ os.chdir(original_cwd)
809
+
810
+ finally:
811
+ del os.environ["TEST_EMPTY_VAR"]
812
+
813
+ def test_env_variable_default_must_be_string(self):
814
+ """Test that non-string defaults are rejected."""
815
+ with TemporaryDirectory() as tmpdir:
816
+ project_root = Path(tmpdir)
817
+
818
+ recipe_file = project_root / "tasktree.yaml"
819
+ recipe_file.write_text("""
820
+ variables:
821
+ port: { env: TEST_PORT, default: 8080 }
822
+
823
+ tasks:
824
+ test:
825
+ cmd: echo test
826
+ """)
827
+
828
+ original_cwd = os.getcwd()
829
+ try:
830
+ os.chdir(project_root)
831
+
832
+ # Should fail at parse time with clear error
833
+ result = self.runner.invoke(app, ["test"], env=self.env)
834
+ self.assertNotEqual(result.exit_code, 0)
835
+
836
+ # Error should mention that default must be string
837
+ output = result.stdout
838
+ self.assertIn("default", output.lower())
839
+ self.assertIn("string", output.lower())
840
+
841
+ finally:
842
+ os.chdir(original_cwd)
843
+
844
+ def test_env_variable_multiple_with_defaults(self):
845
+ """Test multiple env variables with defaults work together."""
846
+ # Set only one env var
847
+ os.environ["TEST_HOST"] = "prod.example.com"
848
+
849
+ try:
850
+ # Ensure other env var is NOT set
851
+ if "TEST_PORT" in os.environ:
852
+ del os.environ["TEST_PORT"]
853
+
854
+ with TemporaryDirectory() as tmpdir:
855
+ project_root = Path(tmpdir)
856
+
857
+ recipe_file = project_root / "tasktree.yaml"
858
+ recipe_file.write_text("""
859
+ variables:
860
+ host: { env: TEST_HOST, default: "localhost" }
861
+ port: { env: TEST_PORT, default: "8080" }
862
+ url: "{{ var.host }}:{{ var.port }}"
863
+
864
+ tasks:
865
+ test:
866
+ outputs: [config.txt]
867
+ cmd: echo "URL={{ var.url }}" > config.txt
868
+ """)
869
+
870
+ original_cwd = os.getcwd()
871
+ try:
872
+ os.chdir(project_root)
873
+
874
+ # Run task
875
+ result = self.runner.invoke(app, ["test"], env=self.env)
876
+ self.assertEqual(result.exit_code, 0)
877
+
878
+ # Verify one used env var, one used default
879
+ output_file = project_root / "config.txt"
880
+ self.assertTrue(output_file.exists())
881
+ content = output_file.read_text().strip()
882
+ self.assertEqual(content, "URL=prod.example.com:8080")
883
+
884
+ finally:
885
+ os.chdir(original_cwd)
886
+
887
+ finally:
888
+ del os.environ["TEST_HOST"]
889
+
890
+ def test_env_variable_default_with_variable_substitution(self):
891
+ """Test default value can contain variable references."""
892
+ # Ensure env var is NOT set
893
+ if "TEST_OVERRIDE" in os.environ:
894
+ del os.environ["TEST_OVERRIDE"]
895
+
896
+ with TemporaryDirectory() as tmpdir:
897
+ project_root = Path(tmpdir)
898
+
899
+ recipe_file = project_root / "tasktree.yaml"
900
+ recipe_file.write_text("""
901
+ variables:
902
+ base_url: "https://api.example.com"
903
+ endpoint: { env: TEST_OVERRIDE, default: "{{ var.base_url }}/users" }
904
+
905
+ tasks:
906
+ test:
907
+ outputs: [config.txt]
908
+ cmd: echo "Endpoint={{ var.endpoint }}" > config.txt
909
+ """)
910
+
911
+ original_cwd = os.getcwd()
912
+ try:
913
+ os.chdir(project_root)
914
+
915
+ # Run task
916
+ result = self.runner.invoke(app, ["test"], env=self.env)
917
+ self.assertEqual(result.exit_code, 0)
918
+
919
+ # Verify default with variable substitution worked
920
+ output_file = project_root / "config.txt"
921
+ self.assertTrue(output_file.exists())
922
+ content = output_file.read_text().strip()
923
+ self.assertEqual(content, "Endpoint=https://api.example.com/users")
924
+
925
+ finally:
926
+ os.chdir(original_cwd)
927
+
696
928
 
697
929
  if __name__ == "__main__":
698
930
  unittest.main()
@@ -31,7 +31,7 @@ class TestParseTaskArgs(unittest.TestCase):
31
31
 
32
32
  def test_parse_task_args_with_defaults(self):
33
33
  """Test default values applied."""
34
- arg_specs = ["environment", "region=us-west-1"]
34
+ arg_specs = ["environment", {"region": {"default": "us-west-1"}}]
35
35
  arg_values = ["production"] # Only provide first arg
36
36
 
37
37
  result = _parse_task_args(arg_specs, arg_values)
@@ -40,7 +40,7 @@ class TestParseTaskArgs(unittest.TestCase):
40
40
 
41
41
  def test_parse_task_args_type_conversion(self):
42
42
  """Test values converted to correct types."""
43
- arg_specs = ["port:int", "debug:bool", "timeout:float"]
43
+ arg_specs = [{"port": {"type": "int"}}, {"debug": {"type": "bool"}}, {"timeout": {"type": "float"}}]
44
44
  arg_values = ["8080", "true", "30.5"]
45
45
 
46
46
  result = _parse_task_args(arg_specs, arg_values)
@@ -76,7 +76,7 @@ class TestParseTaskArgs(unittest.TestCase):
76
76
 
77
77
  def test_parse_task_args_invalid_type(self):
78
78
  """Test error for invalid type conversion."""
79
- arg_specs = ["port:int"]
79
+ arg_specs = [{"port": {"type": "int"}}]
80
80
  arg_values = ["not_a_number"]
81
81
 
82
82
  with self.assertRaises(typer.Exit):
@@ -93,7 +93,7 @@ class TestParseTaskArgs(unittest.TestCase):
93
93
 
94
94
  def test_parse_task_args_mixed(self):
95
95
  """Test mixing positional and named arguments."""
96
- arg_specs = ["environment", "region", "verbose:bool"]
96
+ arg_specs = ["environment", "region", {"verbose": {"type": "bool"}}]
97
97
  arg_values = ["production", "region=us-east-1", "verbose=true"]
98
98
 
99
99
  result = _parse_task_args(arg_specs, arg_values)
@@ -18,7 +18,7 @@ class TestDependencyParsing(unittest.TestCase):
18
18
  self.task_with_args = Task(
19
19
  name="process",
20
20
  cmd="echo mode={{arg.mode}} verbose={{arg.verbose}}",
21
- args=["mode", "verbose=false"],
21
+ args=["mode", {"verbose": {"default": "false"}}],
22
22
  )
23
23
  self.task_no_args = Task(
24
24
  name="build",
@@ -25,7 +25,7 @@ class TestFormatTaskArguments(unittest.TestCase):
25
25
 
26
26
  def test_format_single_optional_argument(self):
27
27
  """Test formatting task with single optional argument."""
28
- result = _format_task_arguments(["environment=production"])
28
+ result = _format_task_arguments([{"environment": {"default": "production"}}])
29
29
  self.assertIn("environment[dim]:str[/dim]", result)
30
30
  self.assertIn("[dim]\\[=production][/dim]", result)
31
31
 
@@ -39,7 +39,7 @@ class TestFormatTaskArguments(unittest.TestCase):
39
39
 
40
40
  def test_format_multiple_optional_arguments(self):
41
41
  """Test formatting task with multiple optional arguments."""
42
- result = _format_task_arguments(["mode=debug", "target=x86_64"])
42
+ result = _format_task_arguments([{"mode": {"default": "debug"}}, {"target": {"default": "x86_64"}}])
43
43
  self.assertIn("mode[dim]:str[/dim]", result)
44
44
  self.assertIn("[dim]\\[=debug][/dim]", result)
45
45
  self.assertIn("target[dim]:str[/dim]", result)
@@ -47,7 +47,7 @@ class TestFormatTaskArguments(unittest.TestCase):
47
47
 
48
48
  def test_format_mixed_required_and_optional_arguments(self):
49
49
  """Test formatting task with mixed required and optional arguments."""
50
- result = _format_task_arguments(["environment", "region=us-west-1"])
50
+ result = _format_task_arguments(["environment", {"region": {"default": "us-west-1"}}])
51
51
  self.assertIn("environment[dim]:str[/dim]", result)
52
52
  self.assertIn("region[dim]:str[/dim]", result)
53
53
  self.assertIn("[dim]\\[=us-west-1][/dim]", result)
@@ -66,7 +66,7 @@ class TestFormatTaskArguments(unittest.TestCase):
66
66
 
67
67
  def test_format_default_values_with_equals_sign(self):
68
68
  """Test formatting shows default values with equals sign."""
69
- result = _format_task_arguments(["port=8080"])
69
+ result = _format_task_arguments([{"port": {"default": "8080"}}])
70
70
  self.assertIn("[dim]\\[=8080][/dim]", result)
71
71
 
72
72
  def test_format_shows_str_type_explicitly(self):
@@ -76,22 +76,22 @@ class TestFormatTaskArguments(unittest.TestCase):
76
76
 
77
77
  def test_format_shows_int_type(self):
78
78
  """Test formatting shows int type."""
79
- result = _format_task_arguments(["port:int"])
79
+ result = _format_task_arguments([{"port": {"type": "int"}}])
80
80
  self.assertIn("port[dim]:int[/dim]", result)
81
81
 
82
82
  def test_format_shows_float_type(self):
83
83
  """Test formatting shows float type."""
84
- result = _format_task_arguments(["timeout:float"])
84
+ result = _format_task_arguments([{"timeout": {"type": "float"}}])
85
85
  self.assertIn("timeout[dim]:float[/dim]", result)
86
86
 
87
87
  def test_format_shows_bool_type(self):
88
88
  """Test formatting shows bool type."""
89
- result = _format_task_arguments(["verbose:bool"])
89
+ result = _format_task_arguments([{"verbose": {"type": "bool"}}])
90
90
  self.assertIn("verbose[dim]:bool[/dim]", result)
91
91
 
92
92
  def test_format_shows_path_type(self):
93
93
  """Test formatting shows path type."""
94
- result = _format_task_arguments(["output:path"])
94
+ result = _format_task_arguments([{"output": {"type": "path"}}])
95
95
  self.assertIn("output[dim]:path[/dim]", result)
96
96
 
97
97
  def test_format_shows_datetime_type(self):
@@ -150,7 +150,7 @@ class TestFormatTaskArguments(unittest.TestCase):
150
150
  def test_format_escapes_rich_markup_in_defaults(self):
151
151
  """Test formatting properly escapes Rich markup in default values."""
152
152
  # Test with brackets in default value
153
- result = _format_task_arguments(["pattern=[a-z]+"])
153
+ result = _format_task_arguments([{"pattern": {"default": "[a-z]+"}}])
154
154
  # The brackets in the default should be escaped
155
155
  self.assertIn("[dim]\\[=[a-z]+][/dim]", result)
156
156
 
@@ -84,7 +84,7 @@ class TestParameterizedGraphConstruction(unittest.TestCase):
84
84
  "process": Task(
85
85
  name="process",
86
86
  cmd="echo mode={{arg.mode}}",
87
- args=["mode=debug"],
87
+ args=[{"mode": {"default": "debug"}}],
88
88
  deps=[],
89
89
  ),
90
90
  "consumer1": Task(
@@ -27,29 +27,23 @@ class TestParseArgSpec(unittest.TestCase):
27
27
  self.assertIsNone(spec.default)
28
28
  self.assertFalse(spec.is_exported)
29
29
 
30
- def test_parse_arg_with_default(self):
31
- """Test parsing argument with default value."""
32
- spec = parse_arg_spec("region=eu-west-1")
33
- self.assertEqual(spec.name,"region")
34
- self.assertEqual(spec.arg_type,"str")
35
- self.assertEqual(spec.default,"eu-west-1")
36
- self.assertFalse(spec.is_exported)
30
+ def test_parse_arg_with_default_raises_error(self):
31
+ """Test that string format with default raises error."""
32
+ with self.assertRaises(ValueError) as context:
33
+ parse_arg_spec("region=eu-west-1")
34
+ self.assertIn("Invalid argument syntax", str(context.exception))
37
35
 
38
- def test_parse_arg_with_type(self):
39
- """Test parsing argument with type."""
40
- spec = parse_arg_spec("port:int")
41
- self.assertEqual(spec.name,"port")
42
- self.assertEqual(spec.arg_type,"int")
43
- self.assertIsNone(spec.default)
44
- self.assertFalse(spec.is_exported)
36
+ def test_parse_arg_with_type_raises_error(self):
37
+ """Test that string format with type raises error."""
38
+ with self.assertRaises(ValueError) as context:
39
+ parse_arg_spec("port:int")
40
+ self.assertIn("Invalid argument syntax", str(context.exception))
45
41
 
46
- def test_parse_arg_with_type_and_default(self):
47
- """Test parsing argument with type and default."""
48
- spec = parse_arg_spec("port:int=8080")
49
- self.assertEqual(spec.name,"port")
50
- self.assertEqual(spec.arg_type,"int")
51
- self.assertEqual(spec.default,"8080")
52
- self.assertFalse(spec.is_exported)
42
+ def test_parse_arg_with_type_and_default_raises_error(self):
43
+ """Test that string format with type and default raises error."""
44
+ with self.assertRaises(ValueError) as context:
45
+ parse_arg_spec("port:int=8080")
46
+ self.assertIn("Invalid argument syntax", str(context.exception))
53
47
 
54
48
  def test_parse_exported_arg(self):
55
49
  """Test parsing exported argument ($ prefix)."""
@@ -59,39 +53,34 @@ class TestParseArgSpec(unittest.TestCase):
59
53
  self.assertIsNone(spec.default)
60
54
  self.assertTrue(spec.is_exported)
61
55
 
62
- def test_parse_exported_arg_with_default(self):
63
- """Test parsing exported argument with default value."""
64
- spec = parse_arg_spec("$user=admin")
65
- self.assertEqual(spec.name,"user")
66
- self.assertEqual(spec.arg_type,"str")
67
- self.assertEqual(spec.default,"admin")
68
- self.assertTrue(spec.is_exported)
56
+ def test_parse_exported_arg_with_default_raises_error(self):
57
+ """Test that exported argument string format with default raises error."""
58
+ with self.assertRaises(ValueError) as context:
59
+ parse_arg_spec("$user=admin")
60
+ self.assertIn("Invalid argument syntax", str(context.exception))
69
61
 
70
62
  def test_parse_exported_arg_with_type_raises_error(self):
71
63
  """Test that exported arguments with type annotations raise error."""
72
64
  with self.assertRaises(ValueError) as context:
73
65
  parse_arg_spec("$server:str")
74
- self.assertIn("Type annotations not allowed on exported arguments", str(context.exception))
75
- self.assertIn("$server:str", str(context.exception))
66
+ self.assertIn("Invalid argument syntax", str(context.exception))
76
67
 
77
68
  def test_parse_exported_arg_with_type_and_default_raises_error(self):
78
69
  """Test that exported arguments with type and default raise error."""
79
70
  with self.assertRaises(ValueError) as context:
80
71
  parse_arg_spec("$port:int=8080")
81
- self.assertIn("Type annotations not allowed on exported arguments", str(context.exception))
72
+ self.assertIn("Invalid argument syntax", str(context.exception))
82
73
 
83
74
  def test_yaml_parses_dollar_prefix_as_literal(self):
84
75
  """Test that PyYAML correctly parses $ prefix as literal text."""
85
76
  yaml_text = """
86
77
  args:
87
78
  - $server
88
- - $user=admin
89
- - port:int=8080
79
+ - environment
90
80
  """
91
81
  data = yaml.safe_load(yaml_text)
92
82
  self.assertEqual(data["args"][0], "$server")
93
- self.assertEqual(data["args"][1], "$user=admin")
94
- self.assertEqual(data["args"][2], "port:int=8080")
83
+ self.assertEqual(data["args"][1], "environment")
95
84
 
96
85
 
97
86
  class TestParseArgSpecYAML(unittest.TestCase):
@@ -416,7 +405,9 @@ tasks:
416
405
  inputs: ["src/**/*.rs"]
417
406
  outputs: [target/release/bin]
418
407
  working_dir: subproject
419
- args: [environment, region=eu-west-1]
408
+ args:
409
+ - environment
410
+ - region: { default: eu-west-1 }
420
411
  cmd: cargo build --release
421
412
  """
422
413
  )
@@ -428,7 +419,9 @@ tasks:
428
419
  self.assertEqual(task.inputs, ["src/**/*.rs"])
429
420
  self.assertEqual(task.outputs, ["target/release/bin"])
430
421
  self.assertEqual(task.working_dir, "subproject")
431
- self.assertEqual(task.args, ["environment", "region=eu-west-1"])
422
+ self.assertEqual(len(task.args), 2)
423
+ self.assertEqual(task.args[0], "environment")
424
+ self.assertIsInstance(task.args[1], dict)
432
425
  self.assertEqual(task.cmd, "cargo build --release")
433
426
 
434
427
  def test_parse_with_imports(self):
@@ -729,7 +722,9 @@ tasks:
729
722
  inputs: ["src/**/*.rs"]
730
723
  outputs: [target/release/bin]
731
724
  working_dir: subproject
732
- args: [environment, region=eu-west-1]
725
+ args:
726
+ - environment
727
+ - region: { default: eu-west-1 }
733
728
  cmd: cargo build --release
734
729
  """)
735
730
 
@@ -747,7 +742,7 @@ imports:
747
742
  self.assertEqual(task.inputs, ["src/**/*.rs"])
748
743
  self.assertEqual(task.outputs, ["target/release/bin"])
749
744
  self.assertEqual(task.working_dir, "subproject")
750
- self.assertEqual(task.args, ["environment", "region=eu-west-1"])
745
+ self.assertEqual(task.args, ["environment", {"region": {"default": "eu-west-1"}}])
751
746
  self.assertEqual(task.cmd, "cargo build --release")
752
747
 
753
748
  def test_cross_import_dependencies(self):
@@ -1708,7 +1703,7 @@ tasks:
1708
1703
  recipe_path = Path(tmpdir) / "tasktree.yaml"
1709
1704
  recipe_path.write_text("""
1710
1705
  variables:
1711
- my_var: { env: TEST_VAR, default: "foo" }
1706
+ my_var: { env: TEST_VAR, foo: "bar" }
1712
1707
 
1713
1708
  tasks:
1714
1709
  test:
@@ -1719,8 +1714,8 @@ tasks:
1719
1714
  parse_recipe(recipe_path)
1720
1715
 
1721
1716
  error_msg = str(cm.exception)
1722
- self.assertIn("extra keys", error_msg.lower())
1723
- self.assertIn("default", error_msg)
1717
+ self.assertIn("found invalid keys", error_msg.lower())
1718
+ self.assertIn("foo", error_msg)
1724
1719
 
1725
1720
  def test_parse_env_variable_invalid_name_empty(self):
1726
1721
  """Test error for { env: } with empty value."""
@@ -2664,7 +2659,7 @@ class TestArgMinMax(unittest.TestCase):
2664
2659
 
2665
2660
  def test_string_format_args_have_no_min_max(self):
2666
2661
  """Test that string format args return None for min/max."""
2667
- spec = parse_arg_spec("count:int=5")
2662
+ spec = parse_arg_spec("count")
2668
2663
  self.assertIsNone(spec.min_val)
2669
2664
  self.assertIsNone(spec.max_val)
2670
2665
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes