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/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 (tasktree.yaml or tt.yaml) in current or parent directories.
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
- for filename in ["tasktree.yaml", "tt.yaml"]:
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
- return recipe_path
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
- if not shell:
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
- args = env_config.get("args", [])
180
- preamble = env_config.get("preamble", "")
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, shell=shell, args=args, preamble=preamble
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
- project_root = recipe_path.parent
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 file's directory
267
- default_working_dir = str(file_dir.relative_to(project_root)) if file_dir != project_root else "."
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.5
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,,
@@ -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,,