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.
- {tasktree-0.0.9 → tasktree-0.0.11}/PKG-INFO +4 -2
- {tasktree-0.0.9 → tasktree-0.0.11}/README.md +3 -1
- {tasktree-0.0.9 → tasktree-0.0.11}/pyproject.toml +1 -1
- {tasktree-0.0.9 → tasktree-0.0.11}/schema/tasktree-schema.json +66 -1
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/cli.py +5 -3
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/docker.py +17 -9
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/hasher.py +8 -1
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/parser.py +40 -30
- tasktree-0.0.11/tests/integration/test_docker_build_args.py +79 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_end_to_end.py +5 -1
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_exported_args.py +24 -6
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_parameterized_dependencies.yaml +2 -2
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_parameterized_deps_execution.py +4 -4
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_cli.py +4 -4
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_dependency_parsing.py +1 -1
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_docker.py +159 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_list_formatting.py +9 -9
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_parameterized_graph.py +1 -1
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_parser.py +102 -40
- {tasktree-0.0.9 → tasktree-0.0.11}/.claude/settings.local.json +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/.github/workflows/claude-code-review.yml +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/.github/workflows/claude.yml +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/.github/workflows/release.yml +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/.github/workflows/test.yml +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/.gitignore +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/.python-version +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/CLAUDE.md +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/example/source.txt +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/example/tasktree.yaml +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/01-basic-variables.md +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/02-env-variable-type.md +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/03-direct-env-substitution.md +0 -0
- {tasktree-0.0.9/requirements → tasktree-0.0.11/requirements/implemented}/04-file-read-variables.md +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/bug-report-dependency-triggering.md +0 -0
- {tasktree-0.0.9/requirements/future → tasktree-0.0.11/requirements/implemented}/docker-task-environments.md +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/shell-environment-requirements.md +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/schema/README.md +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/schema/vscode-settings-snippet.json +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/src/__init__.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/__init__.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/executor.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/graph.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/state.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/substitution.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/tasks.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/src/tasktree/types.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tasktree.yaml +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/__init__.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/test_docker_basic.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/test_docker_environment.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/test_docker_ownership.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/e2e/test_docker_volumes.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_arg_choices.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_arg_min_max.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_builtin_variables.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_clean_state.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_cli_options.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_dependency_execution.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_input_detection.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_missing_outputs.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_nested_imports.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_state_persistence.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_variables.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/integration/test_working_directory.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_environment_tracking.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_executor.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_graph.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_hasher.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_state.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_substitution.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_tasks.py +0 -0
- {tasktree-0.0.9 → tasktree-0.0.11}/tests/unit/test_types.py +0 -0
- {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.
|
|
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:
|
|
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:
|
|
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)
|
|
@@ -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:
|
|
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":
|
|
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
|
|
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
|
-
#
|
|
1319
|
-
if "=" in arg_spec:
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
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=
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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."""
|
|
@@ -37,8 +37,8 @@ class TestParameterizedDependencyExecution(unittest.TestCase):
|
|
|
37
37
|
tasks:
|
|
38
38
|
build:
|
|
39
39
|
args:
|
|
40
|
-
- mode
|
|
41
|
-
- optimize:bool
|
|
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
|
|
155
|
-
- pretty:bool
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
|
31
|
-
"""Test
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
self.
|
|
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
|
|
39
|
-
"""Test
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
self.
|
|
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
|
|
47
|
-
"""Test
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
self.
|
|
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
|
|
63
|
-
"""Test
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
self.
|
|
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("
|
|
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("
|
|
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
|
-
-
|
|
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], "
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree-0.0.9/requirements → tasktree-0.0.11/requirements/implemented}/04-file-read-variables.md
RENAMED
|
File without changes
|
{tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/bug-report-dependency-triggering.md
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree-0.0.9 → tasktree-0.0.11}/requirements/implemented/shell-environment-requirements.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|