tasktree 0.0.5__py3-none-any.whl → 0.0.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tasktree/cli.py +66 -31
- tasktree/docker.py +413 -0
- tasktree/executor.py +268 -31
- tasktree/graph.py +30 -1
- tasktree/hasher.py +27 -0
- tasktree/parser.py +118 -15
- tasktree/state.py +1 -1
- {tasktree-0.0.5.dist-info → tasktree-0.0.7.dist-info}/METADATA +33 -47
- tasktree-0.0.7.dist-info/RECORD +14 -0
- tasktree-0.0.5.dist-info/RECORD +0 -13
- {tasktree-0.0.5.dist-info → tasktree-0.0.7.dist-info}/WHEEL +0 -0
- {tasktree-0.0.5.dist-info → tasktree-0.0.7.dist-info}/entry_points.txt +0 -0
tasktree/parser.py
CHANGED
|
@@ -16,12 +16,24 @@ class CircularImportError(Exception):
|
|
|
16
16
|
|
|
17
17
|
@dataclass
|
|
18
18
|
class Environment:
|
|
19
|
-
"""Represents an execution environment configuration.
|
|
19
|
+
"""Represents an execution environment configuration.
|
|
20
|
+
|
|
21
|
+
Can be either a shell environment or a Docker environment:
|
|
22
|
+
- Shell environment: has 'shell' field, executes directly on host
|
|
23
|
+
- Docker environment: has 'dockerfile' field, executes in container
|
|
24
|
+
"""
|
|
20
25
|
|
|
21
26
|
name: str
|
|
22
|
-
shell: str
|
|
27
|
+
shell: str = "" # Path to shell (required for shell envs, optional for Docker)
|
|
23
28
|
args: list[str] = field(default_factory=list)
|
|
24
29
|
preamble: str = ""
|
|
30
|
+
# Docker-specific fields (presence of dockerfile indicates Docker environment)
|
|
31
|
+
dockerfile: str = "" # Path to Dockerfile
|
|
32
|
+
context: str = "" # Path to build context directory
|
|
33
|
+
volumes: list[str] = field(default_factory=list) # Volume mounts
|
|
34
|
+
ports: list[str] = field(default_factory=list) # Port mappings
|
|
35
|
+
env_vars: dict[str, str] = field(default_factory=dict) # Environment variables
|
|
36
|
+
working_dir: str = "" # Working directory (container or host)
|
|
25
37
|
|
|
26
38
|
def __post_init__(self):
|
|
27
39
|
"""Ensure args is always a list."""
|
|
@@ -94,13 +106,25 @@ class Recipe:
|
|
|
94
106
|
|
|
95
107
|
|
|
96
108
|
def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
97
|
-
"""Find recipe file
|
|
109
|
+
"""Find recipe file in current or parent directories.
|
|
110
|
+
|
|
111
|
+
Looks for recipe files matching these patterns (in order of preference):
|
|
112
|
+
- tasktree.yaml
|
|
113
|
+
- tasktree.yml
|
|
114
|
+
- tt.yaml
|
|
115
|
+
- *.tasks
|
|
116
|
+
|
|
117
|
+
If multiple recipe files are found in the same directory, raises ValueError
|
|
118
|
+
with instructions to use --tasks option.
|
|
98
119
|
|
|
99
120
|
Args:
|
|
100
121
|
start_dir: Directory to start searching from (defaults to cwd)
|
|
101
122
|
|
|
102
123
|
Returns:
|
|
103
124
|
Path to recipe file if found, None otherwise
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ValueError: If multiple recipe files found in the same directory
|
|
104
128
|
"""
|
|
105
129
|
if start_dir is None:
|
|
106
130
|
start_dir = Path.cwd()
|
|
@@ -109,10 +133,30 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
|
109
133
|
|
|
110
134
|
# Search up the directory tree
|
|
111
135
|
while True:
|
|
112
|
-
|
|
136
|
+
candidates = []
|
|
137
|
+
|
|
138
|
+
# Check for exact filenames first
|
|
139
|
+
for filename in ["tasktree.yaml", "tasktree.yml", "tt.yaml"]:
|
|
113
140
|
recipe_path = current / filename
|
|
114
141
|
if recipe_path.exists():
|
|
115
|
-
|
|
142
|
+
candidates.append(recipe_path)
|
|
143
|
+
|
|
144
|
+
# Check for *.tasks files
|
|
145
|
+
for tasks_file in current.glob("*.tasks"):
|
|
146
|
+
if tasks_file.is_file():
|
|
147
|
+
candidates.append(tasks_file)
|
|
148
|
+
|
|
149
|
+
if len(candidates) > 1:
|
|
150
|
+
# Multiple recipe files found - ambiguous
|
|
151
|
+
filenames = [c.name for c in candidates]
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"Multiple recipe files found in {current}:\n"
|
|
154
|
+
f" {', '.join(filenames)}\n\n"
|
|
155
|
+
f"Please specify which file to use with --tasks (-T):\n"
|
|
156
|
+
f" tt --tasks {filenames[0]} <task-name>"
|
|
157
|
+
)
|
|
158
|
+
elif len(candidates) == 1:
|
|
159
|
+
return candidates[0]
|
|
116
160
|
|
|
117
161
|
# Move to parent directory
|
|
118
162
|
parent = current.parent
|
|
@@ -169,28 +213,83 @@ def _parse_file_with_env(
|
|
|
169
213
|
f"Environment '{env_name}' must be a dictionary"
|
|
170
214
|
)
|
|
171
215
|
|
|
172
|
-
# Parse environment configuration
|
|
216
|
+
# Parse common environment configuration
|
|
173
217
|
shell = env_config.get("shell", "")
|
|
174
|
-
|
|
218
|
+
args = env_config.get("args", [])
|
|
219
|
+
preamble = env_config.get("preamble", "")
|
|
220
|
+
working_dir = env_config.get("working_dir", "")
|
|
221
|
+
|
|
222
|
+
# Parse Docker-specific fields
|
|
223
|
+
dockerfile = env_config.get("dockerfile", "")
|
|
224
|
+
context = env_config.get("context", "")
|
|
225
|
+
volumes = env_config.get("volumes", [])
|
|
226
|
+
ports = env_config.get("ports", [])
|
|
227
|
+
env_vars = env_config.get("env_vars", {})
|
|
228
|
+
|
|
229
|
+
# Validate environment type
|
|
230
|
+
if not shell and not dockerfile:
|
|
175
231
|
raise ValueError(
|
|
176
|
-
f"Environment '{env_name}' must specify 'shell'"
|
|
232
|
+
f"Environment '{env_name}' must specify either 'shell' "
|
|
233
|
+
f"(for shell environments) or 'dockerfile' (for Docker environments)"
|
|
177
234
|
)
|
|
178
235
|
|
|
179
|
-
|
|
180
|
-
|
|
236
|
+
# Validate Docker environment requirements
|
|
237
|
+
if dockerfile and not context:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"Docker environment '{env_name}' must specify 'context' "
|
|
240
|
+
f"when 'dockerfile' is specified"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Validate that Dockerfile exists if specified
|
|
244
|
+
if dockerfile:
|
|
245
|
+
dockerfile_path = project_root / dockerfile
|
|
246
|
+
if not dockerfile_path.exists():
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"Environment '{env_name}': Dockerfile not found at {dockerfile_path}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Validate that context directory exists if specified
|
|
252
|
+
if context:
|
|
253
|
+
context_path = project_root / context
|
|
254
|
+
if not context_path.exists():
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"Environment '{env_name}': context directory not found at {context_path}"
|
|
257
|
+
)
|
|
258
|
+
if not context_path.is_dir():
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"Environment '{env_name}': context must be a directory, got {context_path}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Validate environment name (must be valid Docker tag)
|
|
264
|
+
if not env_name.replace("-", "").replace("_", "").isalnum():
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"Environment name '{env_name}' must be alphanumeric "
|
|
267
|
+
f"(with optional hyphens and underscores)"
|
|
268
|
+
)
|
|
181
269
|
|
|
182
270
|
environments[env_name] = Environment(
|
|
183
|
-
name=env_name,
|
|
271
|
+
name=env_name,
|
|
272
|
+
shell=shell,
|
|
273
|
+
args=args,
|
|
274
|
+
preamble=preamble,
|
|
275
|
+
dockerfile=dockerfile,
|
|
276
|
+
context=context,
|
|
277
|
+
volumes=volumes,
|
|
278
|
+
ports=ports,
|
|
279
|
+
env_vars=env_vars,
|
|
280
|
+
working_dir=working_dir,
|
|
184
281
|
)
|
|
185
282
|
|
|
186
283
|
return tasks, environments, default_env
|
|
187
284
|
|
|
188
285
|
|
|
189
|
-
def parse_recipe(recipe_path: Path) -> Recipe:
|
|
286
|
+
def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
|
|
190
287
|
"""Parse a recipe file and handle imports recursively.
|
|
191
288
|
|
|
192
289
|
Args:
|
|
193
290
|
recipe_path: Path to the main recipe file
|
|
291
|
+
project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
|
|
292
|
+
When using --tasks option, this should be the current working directory.
|
|
194
293
|
|
|
195
294
|
Returns:
|
|
196
295
|
Recipe object with all tasks (including recursively imported tasks)
|
|
@@ -204,7 +303,9 @@ def parse_recipe(recipe_path: Path) -> Recipe:
|
|
|
204
303
|
if not recipe_path.exists():
|
|
205
304
|
raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
|
|
206
305
|
|
|
207
|
-
|
|
306
|
+
# Default project root to recipe file's parent if not specified
|
|
307
|
+
if project_root is None:
|
|
308
|
+
project_root = recipe_path.parent
|
|
208
309
|
|
|
209
310
|
# Parse main file - it will recursively handle all imports
|
|
210
311
|
tasks, environments, default_env = _parse_file_with_env(
|
|
@@ -263,8 +364,9 @@ def _parse_file(
|
|
|
263
364
|
tasks: dict[str, Task] = {}
|
|
264
365
|
file_dir = file_path.parent
|
|
265
366
|
|
|
266
|
-
# Default working directory is the
|
|
267
|
-
|
|
367
|
+
# Default working directory is the project root (where tt is invoked)
|
|
368
|
+
# NOT the directory where the tasks file is located
|
|
369
|
+
default_working_dir = "."
|
|
268
370
|
|
|
269
371
|
# Track local import namespaces for dependency rewriting
|
|
270
372
|
local_import_namespaces: set[str] = set()
|
|
@@ -388,6 +490,7 @@ def _parse_file(
|
|
|
388
490
|
working_dir=working_dir,
|
|
389
491
|
args=task_data.get("args", []),
|
|
390
492
|
source_file=str(file_path),
|
|
493
|
+
env=task_data.get("env", ""),
|
|
391
494
|
)
|
|
392
495
|
|
|
393
496
|
tasks[full_name] = task
|
tasktree/state.py
CHANGED
|
@@ -13,7 +13,7 @@ class TaskState:
|
|
|
13
13
|
"""State for a single task execution."""
|
|
14
14
|
|
|
15
15
|
last_run: float
|
|
16
|
-
input_state: dict[str, float] = field(default_factory=dict)
|
|
16
|
+
input_state: dict[str, float | str] = field(default_factory=dict)
|
|
17
17
|
|
|
18
18
|
def to_dict(self) -> dict[str, Any]:
|
|
19
19
|
"""Convert to dictionary for JSON serialization."""
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasktree
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.7
|
|
4
4
|
Summary: A task automation tool with incremental execution
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: click>=8.1.0
|
|
7
7
|
Requires-Dist: colorama>=0.4.6
|
|
8
|
+
Requires-Dist: pathspec>=0.11.0
|
|
8
9
|
Requires-Dist: pyyaml>=6.0
|
|
9
10
|
Requires-Dist: rich>=13.0.0
|
|
10
11
|
Requires-Dist: typer>=0.9.0
|
|
@@ -108,52 +109,6 @@ Boom! Done. `build` will always run, because there's no sensible way to know wha
|
|
|
108
109
|
|
|
109
110
|
This is a toy example, but you can image how it plays out on a more complex project.
|
|
110
111
|
|
|
111
|
-
## Migrating from v1.x to v2.0
|
|
112
|
-
|
|
113
|
-
Version 2.0 requires all task definitions to be under a top-level `tasks:` key.
|
|
114
|
-
|
|
115
|
-
### Quick Migration
|
|
116
|
-
|
|
117
|
-
Wrap your existing tasks in a `tasks:` block:
|
|
118
|
-
|
|
119
|
-
```yaml
|
|
120
|
-
# Before (v1.x)
|
|
121
|
-
build:
|
|
122
|
-
cmd: cargo build
|
|
123
|
-
|
|
124
|
-
# After (v2.0)
|
|
125
|
-
tasks:
|
|
126
|
-
build:
|
|
127
|
-
cmd: cargo build
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
### Why This Change?
|
|
131
|
-
|
|
132
|
-
1. **Clearer structure**: Explicit separation of tasks from configuration
|
|
133
|
-
2. **No naming conflicts**: You can now create tasks named "imports" or "environments"
|
|
134
|
-
3. **Better error messages**: More helpful validation errors
|
|
135
|
-
4. **Consistency**: All recipe files use the same format
|
|
136
|
-
|
|
137
|
-
### Error Messages
|
|
138
|
-
|
|
139
|
-
If you forget to update, you'll see a clear error:
|
|
140
|
-
|
|
141
|
-
```
|
|
142
|
-
Invalid recipe format in tasktree.yaml
|
|
143
|
-
|
|
144
|
-
Task definitions must be under a top-level "tasks:" key.
|
|
145
|
-
|
|
146
|
-
Found these keys at root level: build, test
|
|
147
|
-
|
|
148
|
-
Did you mean:
|
|
149
|
-
|
|
150
|
-
tasks:
|
|
151
|
-
build:
|
|
152
|
-
cmd: ...
|
|
153
|
-
test:
|
|
154
|
-
cmd: ...
|
|
155
|
-
```
|
|
156
|
-
|
|
157
112
|
## Installation
|
|
158
113
|
|
|
159
114
|
### From PyPI (Recommended)
|
|
@@ -188,6 +143,37 @@ cd tasktree
|
|
|
188
143
|
pipx install .
|
|
189
144
|
```
|
|
190
145
|
|
|
146
|
+
## Editor Support
|
|
147
|
+
|
|
148
|
+
Task Tree includes a [JSON Schema](schema/tasktree-schema.json) that provides autocomplete, validation, and documentation in modern editors.
|
|
149
|
+
|
|
150
|
+
### VS Code
|
|
151
|
+
|
|
152
|
+
Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then add to your workspace `.vscode/settings.json`:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"yaml.schemas": {
|
|
157
|
+
"https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
|
|
158
|
+
"tasktree.yaml",
|
|
159
|
+
"tt.yaml"
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Or add a comment at the top of your `tasktree.yaml`:
|
|
166
|
+
|
|
167
|
+
```yaml
|
|
168
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
|
|
169
|
+
|
|
170
|
+
tasks:
|
|
171
|
+
build:
|
|
172
|
+
cmd: cargo build
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
See [schema/README.md](schema/README.md) for IntelliJ/PyCharm and command-line validation.
|
|
176
|
+
|
|
191
177
|
## Quick Start
|
|
192
178
|
|
|
193
179
|
Create a `tasktree.yaml` (or `tt.yaml`) in your project:
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
+
tasktree/cli.py,sha256=0xusNitT1AtLgR3guUsupnHSXJ0_C749Dx7dfYCENJA,15233
|
|
3
|
+
tasktree/docker.py,sha256=duIT5HkGBvLNOPdbgdXuqqUSwJgdWsb4Sxv_HVm8hzA,13118
|
|
4
|
+
tasktree/executor.py,sha256=8xNKPYkekhaGd_gCO7PT7E-n0JeHVtDgIbxACuvPUzU,28707
|
|
5
|
+
tasktree/graph.py,sha256=lA3ExNM_ag0AlC6iW20unseCjRg5wCZXbmXs2M6TnQw,5578
|
|
6
|
+
tasktree/hasher.py,sha256=dCyakihE4rHoOVCbt8hgTQZVuez3P1V0SrWUl-aM2Tw,1670
|
|
7
|
+
tasktree/parser.py,sha256=gkbzlTOwudJsU5gvgSCpVVY2GoYZlo_kLBVsIRbeZiU,19076
|
|
8
|
+
tasktree/state.py,sha256=Cktl4D8iDZVd55aO2LqVyPrc-BnljkesxxkcMcdcfOY,3541
|
|
9
|
+
tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
|
|
10
|
+
tasktree/types.py,sha256=w--sKjRTc8mGYkU5eAduqV86SolDqOYspAPuVKIuSQQ,3797
|
|
11
|
+
tasktree-0.0.7.dist-info/METADATA,sha256=YthtlFiUCaHTgO8n5jUsiJQ3py4pQ23_Zh3Ycshi4fk,17439
|
|
12
|
+
tasktree-0.0.7.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
tasktree-0.0.7.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
14
|
+
tasktree-0.0.7.dist-info/RECORD,,
|
tasktree-0.0.5.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
-
tasktree/cli.py,sha256=jeLFmyFjTeF6JXDPLI46H5Xao9br68tPWL7D6LbC7u0,13272
|
|
3
|
-
tasktree/executor.py,sha256=_E37tShHuiOj0Mvx2GbS9y3GIozC3hpzAVhAjbvYJqg,18638
|
|
4
|
-
tasktree/graph.py,sha256=9ngfg93y7EkOIN_lUQa0u-JhnwiMN1UdQQvIFw8RYCE,4181
|
|
5
|
-
tasktree/hasher.py,sha256=puJey9wF_p37k_xqjhYr_6ICsbAfrTBWHec6MqKV4BU,814
|
|
6
|
-
tasktree/parser.py,sha256=5WXoqN2cUtO9icIPDSvK5889XinBcAQMBCyNoJ2ZO6w,14152
|
|
7
|
-
tasktree/state.py,sha256=rxKtS3SbsPtAuraHbN807RGWfoYYkQ3pe8CxUstwo2k,3535
|
|
8
|
-
tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
|
|
9
|
-
tasktree/types.py,sha256=w--sKjRTc8mGYkU5eAduqV86SolDqOYspAPuVKIuSQQ,3797
|
|
10
|
-
tasktree-0.0.5.dist-info/METADATA,sha256=ncZRu7zCKZRd5YGG5fAWAL1TtH1mXbtnTqAne3RHdeA,17456
|
|
11
|
-
tasktree-0.0.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
-
tasktree-0.0.5.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
13
|
-
tasktree-0.0.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|