tasktree 0.0.9__tar.gz → 0.0.11__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.9 → tasktree-0.0.11}/PKG-INFO +4 -2
  2. {tasktree-0.0.9 → tasktree-0.0.11}/README.md +3 -1
  3. {tasktree-0.0.9 → tasktree-0.0.11}/pyproject.toml +1 -1
  4. {tasktree-0.0.9 → tasktree-0.0.11}/schema/tasktree-schema.json +66 -1
  5. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/cli.py +5 -3
  6. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/docker.py +17 -9
  7. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/hasher.py +8 -1
  8. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/parser.py +40 -30
  9. tasktree-0.0.11/tests/integration/test_docker_build_args.py +79 -0
  10. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_end_to_end.py +5 -1
  11. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_exported_args.py +24 -6
  12. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_parameterized_dependencies.yaml +2 -2
  13. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_parameterized_deps_execution.py +4 -4
  14. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_cli.py +4 -4
  15. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_dependency_parsing.py +1 -1
  16. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_docker.py +159 -0
  17. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_list_formatting.py +9 -9
  18. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_parameterized_graph.py +1 -1
  19. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_parser.py +102 -40
  20. {tasktree-0.0.9 → tasktree-0.0.11}/.claude/settings.local.json +0 -0
  21. {tasktree-0.0.9 → tasktree-0.0.11}/.github/workflows/claude-code-review.yml +0 -0
  22. {tasktree-0.0.9 → tasktree-0.0.11}/.github/workflows/claude.yml +0 -0
  23. {tasktree-0.0.9 → tasktree-0.0.11}/.github/workflows/release.yml +0 -0
  24. {tasktree-0.0.9 → tasktree-0.0.11}/.github/workflows/test.yml +0 -0
  25. {tasktree-0.0.9 → tasktree-0.0.11}/.gitignore +0 -0
  26. {tasktree-0.0.9 → tasktree-0.0.11}/.python-version +0 -0
  27. {tasktree-0.0.9 → tasktree-0.0.11}/CLAUDE.md +0 -0
  28. {tasktree-0.0.9 → tasktree-0.0.11}/example/source.txt +0 -0
  29. {tasktree-0.0.9 → tasktree-0.0.11}/example/tasktree.yaml +0 -0
  30. {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/01-basic-variables.md +0 -0
  31. {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/02-env-variable-type.md +0 -0
  32. {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/03-direct-env-substitution.md +0 -0
  33. {tasktree-0.0.9/requirements → tasktree-0.0.11/requirements/implemented}/04-file-read-variables.md +0 -0
  34. {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/bug-report-dependency-triggering.md +0 -0
  35. {tasktree-0.0.9/requirements/future → tasktree-0.0.11/requirements/implemented}/docker-task-environments.md +0 -0
  36. {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/shell-environment-requirements.md +0 -0
  37. {tasktree-0.0.9 → tasktree-0.0.11}/schema/README.md +0 -0
  38. {tasktree-0.0.9 → tasktree-0.0.11}/schema/vscode-settings-snippet.json +0 -0
  39. {tasktree-0.0.9 → tasktree-0.0.11}/src/__init__.py +0 -0
  40. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/__init__.py +0 -0
  41. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/executor.py +0 -0
  42. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/graph.py +0 -0
  43. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/state.py +0 -0
  44. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/substitution.py +0 -0
  45. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/tasks.py +0 -0
  46. {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/types.py +0 -0
  47. {tasktree-0.0.9 → tasktree-0.0.11}/tasktree.yaml +0 -0
  48. {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/__init__.py +0 -0
  49. {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/test_docker_basic.py +0 -0
  50. {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/test_docker_environment.py +0 -0
  51. {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/test_docker_ownership.py +0 -0
  52. {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/test_docker_volumes.py +0 -0
  53. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_arg_choices.py +0 -0
  54. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_arg_min_max.py +0 -0
  55. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_builtin_variables.py +0 -0
  56. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_clean_state.py +0 -0
  57. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_cli_options.py +0 -0
  58. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_dependency_execution.py +0 -0
  59. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_input_detection.py +0 -0
  60. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_missing_outputs.py +0 -0
  61. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_nested_imports.py +0 -0
  62. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_state_persistence.py +0 -0
  63. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_variables.py +0 -0
  64. {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_working_directory.py +0 -0
  65. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_environment_tracking.py +0 -0
  66. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_executor.py +0 -0
  67. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_graph.py +0 -0
  68. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_hasher.py +0 -0
  69. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_state.py +0 -0
  70. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_substitution.py +0 -0
  71. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_tasks.py +0 -0
  72. {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_types.py +0 -0
  73. {tasktree-0.0.9 → tasktree-0.0.11}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree
3
- Version: 0.0.9
3
+ Version: 0.0.11
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)
@@ -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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tasktree"
3
- version = "0.0.9"
3
+ version = "0.0.11"
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
  """
@@ -87,16 +87,24 @@ class DockerManager:
87
87
 
88
88
  # Build the image
89
89
  try:
90
+ docker_build_cmd = [
91
+ "docker",
92
+ "build",
93
+ "-t",
94
+ image_tag,
95
+ "-f",
96
+ str(dockerfile_path),
97
+ ]
98
+
99
+ # Add build args if environment has them (docker environments use dict for args)
100
+ if isinstance(env.args, dict):
101
+ for arg_name, arg_value in env.args.items():
102
+ docker_build_cmd.extend(["--build-arg", f"{arg_name}={arg_value}"])
103
+
104
+ docker_build_cmd.append(str(context_path))
105
+
90
106
  subprocess.run(
91
- [
92
- "docker",
93
- "build",
94
- "-t",
95
- image_tag,
96
- "-f",
97
- str(dockerfile_path),
98
- str(context_path),
99
- ],
107
+ docker_build_cmd,
100
108
  check=True,
101
109
  capture_output=False, # Show build output to user
102
110
  )
@@ -104,9 +104,16 @@ def hash_environment_definition(env) -> str:
104
104
  # Import inside function to avoid circular dependency
105
105
  from tasktree.parser import Environment
106
106
 
107
+ # Handle args - can be list (shell args) or dict (docker build args)
108
+ args_value = env.args
109
+ if isinstance(env.args, dict):
110
+ args_value = dict(sorted(env.args.items())) # Sort dict for determinism
111
+ elif isinstance(env.args, list):
112
+ args_value = sorted(env.args) # Sort list for determinism
113
+
107
114
  data = {
108
115
  "shell": env.shell,
109
- "args": sorted(env.args), # Sort for determinism
116
+ "args": args_value,
110
117
  "preamble": env.preamble,
111
118
  "dockerfile": env.dockerfile,
112
119
  "context": env.context,
@@ -31,7 +31,7 @@ class Environment:
31
31
 
32
32
  name: str
33
33
  shell: str = "" # Path to shell (required for shell envs, optional for Docker)
34
- args: list[str] = field(default_factory=list)
34
+ args: list[str] | dict[str, str] = field(default_factory=list) # Shell args (list) or Docker build args (dict)
35
35
  preamble: str = ""
36
36
  # Docker-specific fields (presence of dockerfile indicates Docker environment)
37
37
  dockerfile: str = "" # Path to Dockerfile
@@ -44,7 +44,7 @@ class Environment:
44
44
  run_as_root: bool = False # If True, skip user mapping (run as root in container)
45
45
 
46
46
  def __post_init__(self):
47
- """Ensure args is always a list."""
47
+ """Ensure args is in the correct format."""
48
48
  if isinstance(self.args, str):
49
49
  self.args = [self.args]
50
50
 
@@ -60,7 +60,7 @@ class Task:
60
60
  inputs: list[str] = field(default_factory=list)
61
61
  outputs: list[str] = field(default_factory=list)
62
62
  working_dir: str = ""
63
- args: list[str] = field(default_factory=list)
63
+ args: list[str | dict[str, Any]] = field(default_factory=list) # Can be strings or dicts (each dict has single key: arg name)
64
64
  source_file: str = "" # Track which file defined this task
65
65
  env: str = "" # Environment name to use for execution
66
66
 
@@ -75,6 +75,19 @@ class Task:
75
75
  if isinstance(self.args, str):
76
76
  self.args = [self.args]
77
77
 
78
+ # Validate args is not a dict (common YAML mistake)
79
+ if isinstance(self.args, dict):
80
+ raise ValueError(
81
+ f"Task '{self.name}' has invalid 'args' syntax.\n\n"
82
+ f"Found dictionary syntax (without dashes):\n"
83
+ f" args:\n"
84
+ f" {list(self.args.keys())[0] if self.args else 'key'}: ...\n\n"
85
+ f"Correct syntax uses list format (with dashes):\n"
86
+ f" args:\n"
87
+ f" - {list(self.args.keys())[0] if self.args else 'key'}: ...\n\n"
88
+ f"Arguments must be defined as a list, not a dictionary."
89
+ )
90
+
78
91
 
79
92
  @dataclass
80
93
  class DependencyInvocation:
@@ -1246,18 +1259,16 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
1246
1259
 
1247
1260
  Supports both string format and dictionary format:
1248
1261
 
1249
- String format:
1262
+ String format (simple names only):
1250
1263
  - Simple name: "argname"
1251
1264
  - Exported (becomes env var): "$argname"
1252
- - With default: "argname=value" or "$argname=value"
1253
- - Legacy type syntax: "argname:type=value" (for backwards compat)
1254
1265
 
1255
1266
  Dictionary format:
1256
1267
  - argname: { default: "value" }
1257
1268
  - argname: { type: int, default: 42 }
1258
1269
  - argname: { type: int, min: 1, max: 100 }
1259
1270
  - argname: { type: str, choices: ["dev", "staging", "prod"] }
1260
- - $argname: { default: "value" } # Exported
1271
+ - $argname: { default: "value" } # Exported (type not allowed)
1261
1272
 
1262
1273
  Args:
1263
1274
  arg_spec: Argument specification (string or dict with single key)
@@ -1315,34 +1326,24 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
1315
1326
  if is_exported:
1316
1327
  arg_spec = arg_spec[1:] # Remove $ prefix
1317
1328
 
1318
- # Split on = to separate name:type from default
1319
- if "=" in arg_spec:
1320
- name_type, default = arg_spec.split("=", 1)
1321
- else:
1322
- name_type = arg_spec
1323
- default = None
1324
-
1325
- # Split on : to separate name from type
1326
- if ":" in name_type:
1327
- name, arg_type = name_type.split(":", 1)
1329
+ # String format only supports simple names (no = or :)
1330
+ if "=" in arg_spec or ":" in arg_spec:
1331
+ raise ValueError(
1332
+ f"Invalid argument syntax: {'$' if is_exported else ''}{arg_spec}\n\n"
1333
+ f"String format only supports simple argument names.\n"
1334
+ f"Use YAML dict format for type annotations, defaults, or constraints:\n"
1335
+ f" args:\n"
1336
+ f" - {'$' if is_exported else ''}{arg_spec.split('=')[0].split(':')[0]}: {{ default: value }}"
1337
+ )
1328
1338
 
1329
- # Exported arguments cannot have type annotations
1330
- if is_exported:
1331
- raise ValueError(
1332
- f"Type annotations not allowed on exported arguments\n"
1333
- f"In argument: ${name}:{arg_type}\n\n"
1334
- f"Exported arguments are always strings. Remove the type annotation:\n"
1335
- f" args: [${name}]"
1336
- )
1337
- else:
1338
- name = name_type
1339
- arg_type = "str"
1339
+ name = arg_spec
1340
+ arg_type = "str"
1340
1341
 
1341
- # String format doesn't support min/max/choices
1342
+ # String format doesn't support min/max/choices/defaults
1342
1343
  return ArgSpec(
1343
1344
  name=name,
1344
1345
  arg_type=arg_type,
1345
- default=default,
1346
+ default=None,
1346
1347
  is_exported=is_exported,
1347
1348
  min_val=None,
1348
1349
  max_val=None,
@@ -1390,6 +1391,15 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
1390
1391
  f"Exported arguments are always strings. Remove the 'type' field"
1391
1392
  )
1392
1393
 
1394
+ # Exported arguments must have string defaults (if any default is provided)
1395
+ if is_exported and default is not None and not isinstance(default, str):
1396
+ raise ValueError(
1397
+ f"Exported argument '${arg_name}' must have a string default value.\n"
1398
+ f"Got: {default!r} (type: {type(default).__name__})\n"
1399
+ f"Exported arguments become environment variables, which are always strings.\n"
1400
+ f"Use a quoted string: ${arg_name}: {{ default: \"{default}\" }}"
1401
+ )
1402
+
1393
1403
  # Validate choices
1394
1404
  if choices is not None:
1395
1405
  # Validate choices is a list
@@ -0,0 +1,79 @@
1
+ """Integration tests for Docker build args functionality."""
2
+
3
+ import os
4
+ import unittest
5
+ from pathlib import Path
6
+ from tempfile import TemporaryDirectory
7
+
8
+ from typer.testing import CliRunner
9
+
10
+ from tasktree.cli import app
11
+
12
+
13
+ class TestDockerBuildArgs(unittest.TestCase):
14
+ """Test Docker build args are passed correctly to docker build."""
15
+
16
+ def setUp(self):
17
+ """Set up test fixtures."""
18
+ self.runner = CliRunner()
19
+ self.env = {"NO_COLOR": "1"}
20
+
21
+ def test_build_args_passed_to_dockerfile(self):
22
+ """Test that build args are passed to docker build and used in Dockerfile."""
23
+ with TemporaryDirectory() as tmpdir:
24
+ project_root = Path(tmpdir)
25
+
26
+ # Create a Dockerfile that uses ARG statements
27
+ dockerfile = project_root / "Dockerfile"
28
+ dockerfile.write_text("""FROM alpine:latest
29
+
30
+ ARG BUILD_VERSION
31
+ ARG BUILD_DATE
32
+ ARG PYTHON_VERSION=3.11
33
+
34
+ RUN echo "Build version: $BUILD_VERSION" > /build-info.txt && \\
35
+ echo "Build date: $BUILD_DATE" >> /build-info.txt && \\
36
+ echo "Python version: $PYTHON_VERSION" >> /build-info.txt
37
+
38
+ CMD ["cat", "/build-info.txt"]
39
+ """)
40
+
41
+ # Create recipe with Docker environment and build args
42
+ recipe_file = project_root / "tasktree.yaml"
43
+ recipe_file.write_text("""
44
+ environments:
45
+ default: builder
46
+ builder:
47
+ dockerfile: ./Dockerfile
48
+ context: .
49
+ args:
50
+ BUILD_VERSION: "1.2.3"
51
+ BUILD_DATE: "2024-01-01"
52
+ PYTHON_VERSION: "3.12"
53
+
54
+ tasks:
55
+ build:
56
+ env: builder
57
+ cmd: echo "Build args test"
58
+ """)
59
+
60
+ original_cwd = os.getcwd()
61
+ try:
62
+ os.chdir(project_root)
63
+
64
+ # Run the task - this will build the Docker image with build args
65
+ # Note: This test will be skipped in CI if Docker is not available
66
+ result = self.runner.invoke(app, ["build"], env=self.env)
67
+
68
+ # If Docker is not available, skip the test
69
+ if "Docker is not available" in result.stdout or result.exit_code != 0:
70
+ self.skipTest("Docker not available in test environment")
71
+
72
+ self.assertEqual(result.exit_code, 0)
73
+
74
+ finally:
75
+ os.chdir(original_cwd)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()
@@ -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}}
@@ -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",
@@ -142,6 +142,39 @@ class TestIsDockerEnvironment(unittest.TestCase):
142
142
  )
143
143
  self.assertFalse(is_docker_environment(env))
144
144
 
145
+ def test_shell_environment_with_list_args(self):
146
+ """Test that shell environments still work with list args (backward compatibility)."""
147
+ # Shell environments should use list args for shell arguments
148
+ env = Environment(
149
+ name="bash",
150
+ shell="bash",
151
+ args=["-c", "-e"], # List of shell arguments
152
+ )
153
+
154
+ # Verify it's recognized as a shell environment (not Docker)
155
+ self.assertFalse(is_docker_environment(env))
156
+
157
+ # Verify args are stored as a list
158
+ self.assertIsInstance(env.args, list)
159
+ self.assertEqual(env.args, ["-c", "-e"])
160
+
161
+ def test_docker_environment_with_dict_args(self):
162
+ """Test that Docker environments use dict args for build arguments."""
163
+ # Docker environments should use dict args for build arguments
164
+ env = Environment(
165
+ name="builder",
166
+ dockerfile="./Dockerfile",
167
+ context=".",
168
+ args={"BUILD_VERSION": "1.0.0", "BUILD_DATE": "2024-01-01"},
169
+ )
170
+
171
+ # Verify it's recognized as a Docker environment
172
+ self.assertTrue(is_docker_environment(env))
173
+
174
+ # Verify args are stored as a dict
175
+ self.assertIsInstance(env.args, dict)
176
+ self.assertEqual(env.args, {"BUILD_VERSION": "1.0.0", "BUILD_DATE": "2024-01-01"})
177
+
145
178
 
146
179
  class TestResolveContainerWorkingDir(unittest.TestCase):
147
180
  """Test container working directory resolution."""
@@ -246,6 +279,132 @@ class TestDockerManager(unittest.TestCase):
246
279
  self.assertEqual(build_call_args[3], "tt-env-builder")
247
280
  self.assertEqual(build_call_args[4], "-f")
248
281
 
282
+ @patch("tasktree.docker.subprocess.run")
283
+ def test_build_command_with_build_args(self, mock_run):
284
+ """Test that docker build command includes --build-arg flags."""
285
+ env = Environment(
286
+ name="builder",
287
+ dockerfile="./Dockerfile",
288
+ context=".",
289
+ args={"FOO": "fooable", "bar": "you're barred!"},
290
+ )
291
+
292
+ # Mock docker inspect returning image ID
293
+ def mock_run_side_effect(*args, **kwargs):
294
+ cmd = args[0]
295
+ if "inspect" in cmd:
296
+ result = Mock()
297
+ result.stdout = "sha256:abc123def456\n"
298
+ return result
299
+ return None
300
+
301
+ mock_run.side_effect = mock_run_side_effect
302
+
303
+ self.manager.ensure_image_built(env)
304
+
305
+ # Check that docker build was called with build args (2nd call, after docker --version)
306
+ build_call_args = mock_run.call_args_list[1][0][0]
307
+
308
+ # Verify basic command structure
309
+ self.assertEqual(build_call_args[0], "docker")
310
+ self.assertEqual(build_call_args[1], "build")
311
+ self.assertEqual(build_call_args[2], "-t")
312
+ self.assertEqual(build_call_args[3], "tt-env-builder")
313
+ self.assertEqual(build_call_args[4], "-f")
314
+
315
+ # Verify build args are included
316
+ self.assertIn("--build-arg", build_call_args)
317
+
318
+ # Find all build arg pairs
319
+ build_args = {}
320
+ for i, arg in enumerate(build_call_args):
321
+ if arg == "--build-arg":
322
+ arg_pair = build_call_args[i + 1]
323
+ key, value = arg_pair.split("=", 1)
324
+ build_args[key] = value
325
+
326
+ # Verify expected build args
327
+ self.assertEqual(build_args["FOO"], "fooable")
328
+ self.assertEqual(build_args["bar"], "you're barred!")
329
+
330
+ @patch("tasktree.docker.subprocess.run")
331
+ def test_build_command_with_empty_build_args(self, mock_run):
332
+ """Test that docker build command works with empty build args dict."""
333
+ env = Environment(
334
+ name="builder",
335
+ dockerfile="./Dockerfile",
336
+ context=".",
337
+ args={},
338
+ )
339
+
340
+ # Mock docker inspect returning image ID
341
+ def mock_run_side_effect(*args, **kwargs):
342
+ cmd = args[0]
343
+ if "inspect" in cmd:
344
+ result = Mock()
345
+ result.stdout = "sha256:abc123def456\n"
346
+ return result
347
+ return None
348
+
349
+ mock_run.side_effect = mock_run_side_effect
350
+
351
+ self.manager.ensure_image_built(env)
352
+
353
+ # Check that docker build was called (2nd call, after docker --version)
354
+ build_call_args = mock_run.call_args_list[1][0][0]
355
+
356
+ # Verify basic command structure
357
+ self.assertEqual(build_call_args[0], "docker")
358
+ self.assertEqual(build_call_args[1], "build")
359
+
360
+ # Verify NO build args are included
361
+ self.assertNotIn("--build-arg", build_call_args)
362
+
363
+ @patch("tasktree.docker.subprocess.run")
364
+ def test_build_command_with_special_characters_in_args(self, mock_run):
365
+ """Test that build args with special characters are handled correctly."""
366
+ env = Environment(
367
+ name="builder",
368
+ dockerfile="./Dockerfile",
369
+ context=".",
370
+ args={
371
+ "API_KEY": "sk-1234_abcd-5678",
372
+ "MESSAGE": "Hello, World!",
373
+ "PATH_WITH_SPACES": "/path/to/my files",
374
+ "SPECIAL_CHARS": "test=value&foo=bar",
375
+ },
376
+ )
377
+
378
+ # Mock docker inspect returning image ID
379
+ def mock_run_side_effect(*args, **kwargs):
380
+ cmd = args[0]
381
+ if "inspect" in cmd:
382
+ result = Mock()
383
+ result.stdout = "sha256:abc123def456\n"
384
+ return result
385
+ return None
386
+
387
+ mock_run.side_effect = mock_run_side_effect
388
+
389
+ self.manager.ensure_image_built(env)
390
+
391
+ # Check that docker build was called (2nd call, after docker --version)
392
+ build_call_args = mock_run.call_args_list[1][0][0]
393
+
394
+ # Find all build arg pairs
395
+ build_args = {}
396
+ for i, arg in enumerate(build_call_args):
397
+ if arg == "--build-arg":
398
+ arg_pair = build_call_args[i + 1]
399
+ key, value = arg_pair.split("=", 1)
400
+ build_args[key] = value
401
+
402
+ # Verify special characters are preserved
403
+ self.assertEqual(build_args["API_KEY"], "sk-1234_abcd-5678")
404
+ self.assertEqual(build_args["MESSAGE"], "Hello, World!")
405
+ self.assertEqual(build_args["PATH_WITH_SPACES"], "/path/to/my files")
406
+ self.assertEqual(build_args["SPECIAL_CHARS"], "test=value&foo=bar")
407
+
249
408
  def test_resolve_volume_mount_relative(self):
250
409
  """Test relative volume path resolution."""
251
410
  volume = "./src:/workspace/src"
@@ -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):
@@ -1540,6 +1535,73 @@ tasks:
1540
1535
  self.assertEqual(len(recipe.tasks), 0)
1541
1536
 
1542
1537
 
1538
+ class TestArgsValidation(unittest.TestCase):
1539
+ """Tests for validating task args must be a list, not a dict."""
1540
+
1541
+ def test_args_dict_syntax_raises_error(self):
1542
+ """Test that dictionary syntax for args raises a helpful error."""
1543
+ with TemporaryDirectory() as tmpdir:
1544
+ recipe_path = Path(tmpdir) / "tasktree.yaml"
1545
+ recipe_path.write_text("""
1546
+ tasks:
1547
+ foo:
1548
+ args:
1549
+ x: {}
1550
+ y: { type: int, default: 10 }
1551
+ cmd: echo x = {{ arg.x }}, y = {{ arg.y }}
1552
+ """)
1553
+
1554
+ with self.assertRaises(ValueError) as cm:
1555
+ parse_recipe(recipe_path)
1556
+
1557
+ error_msg = str(cm.exception)
1558
+ self.assertIn("invalid 'args' syntax", error_msg)
1559
+ self.assertIn("dictionary syntax", error_msg)
1560
+ self.assertIn("list format", error_msg)
1561
+ self.assertIn("with dashes", error_msg)
1562
+ # Should show the first key as an example
1563
+ self.assertIn("x", error_msg)
1564
+
1565
+ def test_args_list_syntax_is_valid(self):
1566
+ """Test that list syntax for args works correctly."""
1567
+ with TemporaryDirectory() as tmpdir:
1568
+ recipe_path = Path(tmpdir) / "tasktree.yaml"
1569
+ recipe_path.write_text("""
1570
+ tasks:
1571
+ foo:
1572
+ args:
1573
+ - x
1574
+ - y: { type: int, default: 10 }
1575
+ cmd: echo x = {{ arg.x }}, y = {{ arg.y }}
1576
+ """)
1577
+
1578
+ # Should parse without error
1579
+ recipe = parse_recipe(recipe_path)
1580
+
1581
+ # Verify the task was parsed correctly
1582
+ self.assertIn("foo", recipe.tasks)
1583
+ task = recipe.tasks["foo"]
1584
+ self.assertEqual(len(task.args), 2)
1585
+
1586
+ def test_args_empty_dict_raises_error(self):
1587
+ """Test that even an empty dict for args raises an error."""
1588
+ with TemporaryDirectory() as tmpdir:
1589
+ recipe_path = Path(tmpdir) / "tasktree.yaml"
1590
+ recipe_path.write_text("""
1591
+ tasks:
1592
+ foo:
1593
+ args: {}
1594
+ cmd: echo hello
1595
+ """)
1596
+
1597
+ with self.assertRaises(ValueError) as cm:
1598
+ parse_recipe(recipe_path)
1599
+
1600
+ error_msg = str(cm.exception)
1601
+ self.assertIn("invalid 'args' syntax", error_msg)
1602
+ self.assertIn("dictionary syntax", error_msg)
1603
+
1604
+
1543
1605
  class TestVariablesParsing(unittest.TestCase):
1544
1606
  """Test parsing of variables section with environment variable support."""
1545
1607
 
@@ -2597,7 +2659,7 @@ class TestArgMinMax(unittest.TestCase):
2597
2659
 
2598
2660
  def test_string_format_args_have_no_min_max(self):
2599
2661
  """Test that string format args return None for min/max."""
2600
- spec = parse_arg_spec("count:int=5")
2662
+ spec = parse_arg_spec("count")
2601
2663
  self.assertIsNone(spec.min_val)
2602
2664
  self.assertIsNone(spec.max_val)
2603
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