tasktree 0.0.21__tar.gz → 0.0.22__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 (75) hide show
  1. {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/test.yml +9 -16
  2. {tasktree-0.0.21 → tasktree-0.0.22}/CLAUDE.md +2 -0
  3. {tasktree-0.0.21 → tasktree-0.0.22}/PKG-INFO +13 -15
  4. {tasktree-0.0.21 → tasktree-0.0.22}/README.md +10 -14
  5. {tasktree-0.0.21 → tasktree-0.0.22}/pyproject.toml +5 -4
  6. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/cli.py +91 -31
  7. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/docker.py +24 -17
  8. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/executor.py +263 -211
  9. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/graph.py +15 -10
  10. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/hasher.py +13 -6
  11. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/parser.py +220 -121
  12. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/state.py +7 -8
  13. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/substitution.py +27 -15
  14. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/types.py +29 -12
  15. {tasktree-0.0.21 → tasktree-0.0.22}/tasktree.yaml +29 -26
  16. {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/__init__.py +5 -1
  17. {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_docker_basic.py +7 -8
  18. {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_docker_environment.py +9 -5
  19. {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_docker_ownership.py +6 -6
  20. {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_docker_volumes.py +8 -13
  21. {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_non_docker.py +8 -12
  22. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_arg_choices.py +23 -9
  23. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_arg_min_max.py +5 -3
  24. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_builtin_variables.py +103 -56
  25. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_clean_state.py +2 -2
  26. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_cli_options.py +45 -46
  27. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_dependency_execution.py +18 -16
  28. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_dependency_outputs.py +14 -28
  29. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_end_to_end.py +55 -9
  30. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_exported_args.py +27 -48
  31. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_input_detection.py +3 -3
  32. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_missing_outputs.py +11 -2
  33. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_nested_imports.py +28 -10
  34. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_parameterized_deps_execution.py +2 -2
  35. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_parameterized_deps_templates.py +10 -8
  36. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_private_tasks_execution.py +14 -6
  37. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_self_references.py +165 -53
  38. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_state_persistence.py +2 -2
  39. tasktree-0.0.22/tests/integration/test_unified_execution.py +215 -0
  40. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_working_directory.py +15 -5
  41. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_cli.py +41 -39
  42. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_dependency_parsing.py +0 -2
  43. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_docker.py +73 -27
  44. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_environment_tracking.py +35 -23
  45. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_executor.py +588 -214
  46. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_graph.py +62 -22
  47. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_hasher.py +1 -0
  48. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_list_formatting.py +61 -47
  49. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_parser.py +384 -418
  50. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_state.py +6 -4
  51. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_substitution.py +105 -54
  52. {tasktree-0.0.21 → tasktree-0.0.22}/uv.lock +126 -3
  53. {tasktree-0.0.21 → tasktree-0.0.22}/.claude/settings.local.json +0 -0
  54. {tasktree-0.0.21 → tasktree-0.0.22}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  55. {tasktree-0.0.21 → tasktree-0.0.22}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  56. {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/claude-code-review.yml +0 -0
  57. {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/claude.yml +0 -0
  58. {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/release.yml +0 -0
  59. {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/validate-pipx-install.yml +0 -0
  60. {tasktree-0.0.21 → tasktree-0.0.22}/.gitignore +0 -0
  61. {tasktree-0.0.21 → tasktree-0.0.22}/.python-version +0 -0
  62. {tasktree-0.0.21 → tasktree-0.0.22}/__init__.py +0 -0
  63. {tasktree-0.0.21 → tasktree-0.0.22}/example/source.txt +0 -0
  64. {tasktree-0.0.21 → tasktree-0.0.22}/example/tasktree.yaml +0 -0
  65. {tasktree-0.0.21 → tasktree-0.0.22}/schema/README.md +0 -0
  66. {tasktree-0.0.21 → tasktree-0.0.22}/schema/tasktree-schema.json +0 -0
  67. {tasktree-0.0.21 → tasktree-0.0.22}/schema/vscode-settings-snippet.json +0 -0
  68. {tasktree-0.0.21 → tasktree-0.0.22}/src/__init__.py +0 -0
  69. {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/__init__.py +0 -0
  70. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_docker_build_args.py +0 -0
  71. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_parameterized_dependencies.yaml +0 -0
  72. {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_variables.py +0 -0
  73. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_parameterized_graph.py +0 -0
  74. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_private_tasks.py +0 -0
  75. {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_types.py +0 -0
@@ -27,31 +27,26 @@ jobs:
27
27
  uses: astral-sh/setup-uv@v4
28
28
 
29
29
  - name: Install dependencies
30
- run: |
31
- uv pip install --system -e ".[dev]"
30
+ run: uv sync --extra dev
32
31
 
33
32
  - name: Pre-pull Docker images for E2E tests
34
33
  if: runner.os == 'Linux'
35
- run: |
36
- docker pull alpine:latest
34
+ run: docker pull alpine:latest
37
35
 
38
36
  - name: Run unit tests
39
- run: |
40
- python -m pytest tests/unit/ -v --tb=short
37
+ run: uv run pytest tests/unit/ --tb=short
41
38
 
42
39
  - name: Run integration tests
43
- run: |
44
- python -m pytest tests/integration/ -v --tb=short
40
+ run: uv run pytest tests/integration/ --tb=short
45
41
 
46
42
  - name: Run E2E Docker tests
47
43
  if: runner.os == 'Linux'
48
- run: |
49
- python -m pytest tests/e2e/ -v --tb=short
44
+ run: uv run pytest tests/e2e/ --tb=short
50
45
 
51
46
  - name: Test CLI commands
52
47
  run: |
53
- python -m tasktree.cli --help
54
- python -m tasktree.cli --list || true
48
+ uv run tt --help
49
+ uv run tt --list || true
55
50
 
56
51
  lint:
57
52
  runs-on: ubuntu-latest
@@ -67,9 +62,7 @@ jobs:
67
62
  uses: astral-sh/setup-uv@v4
68
63
 
69
64
  - name: Install dependencies
70
- run: |
71
- uv pip install --system -e ".[dev]"
65
+ run: uv sync --extra dev
72
66
 
73
67
  - name: Check code can be imported
74
- run: |
75
- python -c "from tasktree import Executor, Recipe, Task; print('Import successful')"
68
+ run: uv run python -c "from tasktree import Executor, Recipe, Task; print('Import successful')"
@@ -154,6 +154,8 @@ environments:
154
154
  env-name:
155
155
  default: true # Make this the default environment
156
156
  shell: /bin/bash # Shell environment
157
+ preamble: | # Optional preamble prepended to all commands
158
+ set -euo pipefail
157
159
  # OR
158
160
  dockerfile: path/to/Dockerfile # Docker environment
159
161
  context: build-context-dir
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree
3
- Version: 0.0.21
3
+ Version: 0.0.22
4
4
  Summary: A task automation tool with incremental execution
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: click>=8.1.0
@@ -10,7 +10,9 @@ Requires-Dist: pyyaml>=6.0
10
10
  Requires-Dist: rich>=13.0.0
11
11
  Requires-Dist: typer>=0.9.0
12
12
  Provides-Extra: dev
13
+ Requires-Dist: black>=26.1.0; extra == 'dev'
13
14
  Requires-Dist: pytest>=9.0.2; extra == 'dev'
15
+ Requires-Dist: ruff>=0.14.14; extra == 'dev'
14
16
  Description-Content-Type: text/markdown
15
17
 
16
18
  # Task Tree (tt)
@@ -255,20 +257,19 @@ tasks:
255
257
  cmd: go build -o dist/binary # Command to execute
256
258
  ```
257
259
 
260
+ **Task name constraints:**
261
+ - Task names cannot contain dots (`.`) - they are reserved for namespacing imported tasks
262
+ - Example: `build.release` is invalid as a task name, but valid as a reference to task `release` in namespace `build`
263
+
258
264
  ### Commands
259
265
 
260
- **Single-line commands** are executed directly via the configured shell:
266
+ All commands are executed by writing them to temporary script files. This provides consistent behavior and better shell syntax support:
261
267
 
262
268
  ```yaml
263
269
  tasks:
264
270
  build:
265
271
  cmd: cargo build --release
266
- ```
267
-
268
- **Multi-line commands** are written to temporary script files for proper execution:
269
272
 
270
- ```yaml
271
- tasks:
272
273
  deploy:
273
274
  cmd: |
274
275
  mkdir -p dist
@@ -276,7 +277,7 @@ tasks:
276
277
  rsync -av dist/ server:/opt/app/
277
278
  ```
278
279
 
279
- Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
280
+ Commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
280
281
 
281
282
  Or use folded blocks for long single-line commands:
282
283
 
@@ -292,7 +293,7 @@ tasks:
292
293
 
293
294
  ### Execution Environments
294
295
 
295
- Configure custom shell environments for task execution:
296
+ Configure custom shell environments for task execution. Use the `preamble` field to add initialization code to all commands:
296
297
 
297
298
  ```yaml
298
299
  environments:
@@ -300,17 +301,14 @@ environments:
300
301
 
301
302
  bash-strict:
302
303
  shell: bash
303
- args: ['-c'] # For single-line: bash -c "command"
304
- preamble: | # For multi-line: prepended to script
304
+ preamble: | # Prepended to all commands
305
305
  set -euo pipefail
306
306
 
307
307
  python:
308
308
  shell: python
309
- args: ['-c']
310
309
 
311
310
  powershell:
312
311
  shell: powershell
313
- args: ['-ExecutionPolicy', 'Bypass', '-Command']
314
312
  preamble: |
315
313
  $ErrorActionPreference = 'Stop'
316
314
 
@@ -339,8 +337,8 @@ tasks:
339
337
  4. Platform default (bash on Unix, cmd on Windows)
340
338
 
341
339
  **Platform defaults** when no environments are configured:
342
- - **Unix/macOS**: bash with `-c` args
343
- - **Windows**: cmd with `/c` args
340
+ - **Unix/macOS**: bash
341
+ - **Windows**: cmd
344
342
 
345
343
  ### Docker Environments
346
344
 
@@ -240,20 +240,19 @@ tasks:
240
240
  cmd: go build -o dist/binary # Command to execute
241
241
  ```
242
242
 
243
+ **Task name constraints:**
244
+ - Task names cannot contain dots (`.`) - they are reserved for namespacing imported tasks
245
+ - Example: `build.release` is invalid as a task name, but valid as a reference to task `release` in namespace `build`
246
+
243
247
  ### Commands
244
248
 
245
- **Single-line commands** are executed directly via the configured shell:
249
+ All commands are executed by writing them to temporary script files. This provides consistent behavior and better shell syntax support:
246
250
 
247
251
  ```yaml
248
252
  tasks:
249
253
  build:
250
254
  cmd: cargo build --release
251
- ```
252
-
253
- **Multi-line commands** are written to temporary script files for proper execution:
254
255
 
255
- ```yaml
256
- tasks:
257
256
  deploy:
258
257
  cmd: |
259
258
  mkdir -p dist
@@ -261,7 +260,7 @@ tasks:
261
260
  rsync -av dist/ server:/opt/app/
262
261
  ```
263
262
 
264
- Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
263
+ Commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
265
264
 
266
265
  Or use folded blocks for long single-line commands:
267
266
 
@@ -277,7 +276,7 @@ tasks:
277
276
 
278
277
  ### Execution Environments
279
278
 
280
- Configure custom shell environments for task execution:
279
+ Configure custom shell environments for task execution. Use the `preamble` field to add initialization code to all commands:
281
280
 
282
281
  ```yaml
283
282
  environments:
@@ -285,17 +284,14 @@ environments:
285
284
 
286
285
  bash-strict:
287
286
  shell: bash
288
- args: ['-c'] # For single-line: bash -c "command"
289
- preamble: | # For multi-line: prepended to script
287
+ preamble: | # Prepended to all commands
290
288
  set -euo pipefail
291
289
 
292
290
  python:
293
291
  shell: python
294
- args: ['-c']
295
292
 
296
293
  powershell:
297
294
  shell: powershell
298
- args: ['-ExecutionPolicy', 'Bypass', '-Command']
299
295
  preamble: |
300
296
  $ErrorActionPreference = 'Stop'
301
297
 
@@ -324,8 +320,8 @@ tasks:
324
320
  4. Platform default (bash on Unix, cmd on Windows)
325
321
 
326
322
  **Platform defaults** when no environments are configured:
327
- - **Unix/macOS**: bash with `-c` args
328
- - **Windows**: cmd with `/c` args
323
+ - **Unix/macOS**: bash
324
+ - **Windows**: cmd
329
325
 
330
326
  ### Docker Environments
331
327
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tasktree"
3
- version = "0.0.21"
3
+ version = "0.0.22"
4
4
  description = "A task automation tool with incremental execution"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -14,9 +14,10 @@ dependencies = [
14
14
  ]
15
15
 
16
16
  [project.optional-dependencies]
17
- dev = [
18
- "pytest>=9.0.2",
19
- ]
17
+ dev = ["pytest>=9.0.2", "black>=26.1.0", "ruff>=0.14.14"]
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
20
21
 
21
22
  [project.scripts]
22
23
  tt = "tasktree.cli:cli"
@@ -21,7 +21,12 @@ from rich.tree import Tree
21
21
 
22
22
  from tasktree import __version__
23
23
  from tasktree.executor import Executor
24
- from tasktree.graph import build_dependency_tree, resolve_execution_order, resolve_dependency_output_references, resolve_self_references
24
+ from tasktree.graph import (
25
+ build_dependency_tree,
26
+ resolve_execution_order,
27
+ resolve_dependency_output_references,
28
+ resolve_self_references,
29
+ )
25
30
  from tasktree.hasher import hash_task, hash_args
26
31
  from tasktree.parser import Recipe, find_recipe_file, parse_arg_spec, parse_recipe
27
32
  from tasktree.state import StateManager
@@ -125,7 +130,9 @@ def _list_tasks(tasks_file: Optional[str] = None):
125
130
  """
126
131
  recipe = _get_recipe(tasks_file)
127
132
  if recipe is None:
128
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
133
+ console.print(
134
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
135
+ )
129
136
  raise typer.Exit(1)
130
137
 
131
138
  # Calculate maximum task name length for fixed-width column (only visible tasks)
@@ -134,13 +141,17 @@ def _list_tasks(tasks_file: Optional[str] = None):
134
141
  task = recipe.get_task(name)
135
142
  if task and not task.private:
136
143
  visible_task_names.append(name)
137
- max_task_name_len = max(len(name) for name in visible_task_names) if visible_task_names else 0
144
+ max_task_name_len = (
145
+ max(len(name) for name in visible_task_names) if visible_task_names else 0
146
+ )
138
147
 
139
148
  # Create borderless table with three columns
140
149
  table = Table(show_edge=False, show_header=False, box=None, padding=(0, 2))
141
150
 
142
- # Command column: fixed width to accommodate longest task name
143
- table.add_column("Command", style="bold cyan", no_wrap=True, width=max_task_name_len)
151
+ # Command column: fixed width to accommodate the longest task name
152
+ table.add_column(
153
+ "Command", style="bold cyan", no_wrap=True, width=max_task_name_len
154
+ )
144
155
 
145
156
  # Arguments column: allow wrapping with sensible max width
146
157
  table.add_column("Arguments", style="white", max_width=60)
@@ -169,7 +180,9 @@ def _show_task(task_name: str, tasks_file: Optional[str] = None):
169
180
  # Pass task_name as root_task for lazy variable evaluation
170
181
  recipe = _get_recipe(tasks_file, root_task=task_name)
171
182
  if recipe is None:
172
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
183
+ console.print(
184
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
185
+ )
173
186
  raise typer.Exit(1)
174
187
 
175
188
  task = recipe.get_task(task_name)
@@ -202,9 +215,9 @@ def _show_task(task_name: str, tasks_file: Optional[str] = None):
202
215
  # Configure YAML dumper to use literal block style for multiline strings
203
216
  def literal_presenter(dumper, data):
204
217
  """Use literal block style (|) for strings containing newlines."""
205
- if '\n' in data:
206
- return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
207
- return dumper.represent_scalar('tag:yaml.org,2002:str', data)
218
+ if "\n" in data:
219
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
220
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data)
208
221
 
209
222
  yaml.add_representer(str, literal_presenter)
210
223
 
@@ -222,7 +235,9 @@ def _show_tree(task_name: str, tasks_file: Optional[str] = None):
222
235
  # Pass task_name as root_task for lazy variable evaluation
223
236
  recipe = _get_recipe(tasks_file, root_task=task_name)
224
237
  if recipe is None:
225
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
238
+ console.print(
239
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
240
+ )
226
241
  raise typer.Exit(1)
227
242
 
228
243
  task = recipe.get_task(task_name)
@@ -307,10 +322,18 @@ def main(
307
322
  is_eager=True,
308
323
  help="Show version and exit",
309
324
  ),
310
- list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
311
- show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
312
- tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
313
- tasks_file: Optional[str] = typer.Option(None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"),
325
+ list_opt: Optional[bool] = typer.Option(
326
+ None, "--list", "-l", help="List all available tasks"
327
+ ),
328
+ show: Optional[str] = typer.Option(
329
+ None, "--show", "-s", help="Show task definition"
330
+ ),
331
+ tree: Optional[str] = typer.Option(
332
+ None, "--tree", "-t", help="Show dependency tree"
333
+ ),
334
+ tasks_file: Optional[str] = typer.Option(
335
+ None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"
336
+ ),
314
337
  init: Optional[bool] = typer.Option(
315
338
  None, "--init", "-i", help="Create a blank tasktree.yaml"
316
339
  ),
@@ -327,7 +350,10 @@ def main(
327
350
  None, "--force", "-f", help="Force re-run all tasks (ignore freshness)"
328
351
  ),
329
352
  only: Optional[bool] = typer.Option(
330
- None, "--only", "-o", help="Run only the specified task, skip dependencies (implies --force)"
353
+ None,
354
+ "--only",
355
+ "-o",
356
+ help="Run only the specified task, skip dependencies (implies --force)",
331
357
  ),
332
358
  env: Optional[str] = typer.Option(
333
359
  None, "--env", "-e", help="Override environment for all tasks"
@@ -374,11 +400,19 @@ def main(
374
400
  if task_args:
375
401
  # --only implies --force
376
402
  force_execution = force or only or False
377
- _execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env, tasks_file=tasks_file)
403
+ _execute_dynamic_task(
404
+ task_args,
405
+ force=force_execution,
406
+ only=only or False,
407
+ env=env,
408
+ tasks_file=tasks_file,
409
+ )
378
410
  else:
379
411
  recipe = _get_recipe(tasks_file)
380
412
  if recipe is None:
381
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
413
+ console.print(
414
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
415
+ )
382
416
  console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
383
417
  raise typer.Exit(1)
384
418
 
@@ -413,13 +447,17 @@ def _clean_state(tasks_file: Optional[str] = None) -> None:
413
447
 
414
448
  if state_path.exists():
415
449
  state_path.unlink()
416
- console.print(f"[green]{get_action_success_string()} Removed {state_path}[/green]")
450
+ console.print(
451
+ f"[green]{get_action_success_string()} Removed {state_path}[/green]"
452
+ )
417
453
  console.print("All tasks will run fresh on next execution")
418
454
  else:
419
455
  console.print(f"[yellow]No state file found at {state_path}[/yellow]")
420
456
 
421
457
 
422
- def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = None) -> Optional[Recipe]:
458
+ def _get_recipe(
459
+ recipe_file: Optional[str] = None, root_task: Optional[str] = None
460
+ ) -> Optional[Recipe]:
423
461
  """
424
462
  Get parsed recipe or None if not found.
425
463
 
@@ -455,7 +493,13 @@ def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = No
455
493
  raise typer.Exit(1)
456
494
 
457
495
 
458
- def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None, tasks_file: Optional[str] = None) -> None:
496
+ def _execute_dynamic_task(
497
+ args: list[str],
498
+ force: bool = False,
499
+ only: bool = False,
500
+ env: Optional[str] = None,
501
+ tasks_file: Optional[str] = None,
502
+ ) -> None:
459
503
  """
460
504
  Execute a task with its dependencies and handle argument parsing.
461
505
 
@@ -477,7 +521,9 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
477
521
  # Pass task_name as root_task for lazy variable evaluation
478
522
  recipe = _get_recipe(tasks_file, root_task=task_name)
479
523
  if recipe is None:
480
- console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
524
+ console.print(
525
+ "[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]"
526
+ )
481
527
  raise typer.Exit(1)
482
528
 
483
529
  # Apply global environment override if provided
@@ -537,7 +583,7 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
537
583
  task.working_dir,
538
584
  task.args,
539
585
  executor._get_effective_env_name(task),
540
- task.deps
586
+ task.deps,
541
587
  )
542
588
 
543
589
  # If task has arguments, append args hash to create unique cache key
@@ -553,9 +599,13 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
553
599
  state.save()
554
600
  try:
555
601
  executor.execute_task(task_name, args_dict, force=force, only=only)
556
- console.print(f"[green]{get_action_success_string()} Task '{task_name}' completed successfully[/green]")
602
+ console.print(
603
+ f"[green]{get_action_success_string()} Task '{task_name}' completed successfully[/green]"
604
+ )
557
605
  except Exception as e:
558
- console.print(f"[red]{get_action_failure_string()} Task '{task_name}' failed: {e}[/red]")
606
+ console.print(
607
+ f"[red]{get_action_failure_string()} Task '{task_name}' failed: {e}[/red]"
608
+ )
559
609
  raise typer.Exit(1)
560
610
 
561
611
 
@@ -577,7 +627,7 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
577
627
  """
578
628
  if not arg_specs:
579
629
  if arg_values:
580
- console.print(f"[red]Task does not accept arguments[/red]")
630
+ console.print("[red]Task does not accept arguments[/red]")
581
631
  raise typer.Exit(1)
582
632
  return {}
583
633
 
@@ -601,7 +651,7 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
601
651
  else:
602
652
  # Positional argument
603
653
  if positional_index >= len(parsed_specs):
604
- console.print(f"[red]Too many arguments[/red]")
654
+ console.print("[red]Too many arguments[/red]")
605
655
  raise typer.Exit(1)
606
656
  spec = parsed_specs[positional_index]
607
657
  arg_value = value_str
@@ -609,13 +659,19 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
609
659
 
610
660
  # Convert value to appropriate type (exported args are always strings)
611
661
  try:
612
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
662
+ click_type = get_click_type(
663
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
664
+ )
613
665
  converted_value = click_type.convert(arg_value, None, None)
614
666
 
615
667
  # Validate choices after type conversion
616
668
  if spec.choices is not None and converted_value not in spec.choices:
617
- console.print(f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]")
618
- console.print(f"Valid choices: {', '.join(repr(c) for c in spec.choices)}")
669
+ console.print(
670
+ f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]"
671
+ )
672
+ console.print(
673
+ f"Valid choices: {', '.join(repr(c) for c in spec.choices)}"
674
+ )
619
675
  raise typer.Exit(1)
620
676
 
621
677
  args_dict[spec.name] = converted_value
@@ -630,10 +686,14 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
630
686
  if spec.name not in args_dict:
631
687
  if spec.default is not None:
632
688
  try:
633
- click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
689
+ click_type = get_click_type(
690
+ spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
691
+ )
634
692
  args_dict[spec.name] = click_type.convert(spec.default, None, None)
635
693
  except Exception as e:
636
- console.print(f"[red]Invalid default value for {spec.name}: {e}[/red]")
694
+ console.print(
695
+ f"[red]Invalid default value for {spec.name}: {e}[/red]"
696
+ )
637
697
  raise typer.Exit(1)
638
698
  else:
639
699
  console.print(f"[red]Missing required argument: {spec.name}[/red]")
@@ -9,10 +9,11 @@ import os
9
9
  import platform
10
10
  import re
11
11
  import subprocess
12
- import time
13
12
  from pathlib import Path
14
13
  from typing import TYPE_CHECKING
15
14
 
15
+ from pathspec import PathSpec
16
+
16
17
  try:
17
18
  import pathspec
18
19
  except ImportError:
@@ -34,7 +35,7 @@ class DockerError(Exception):
34
35
  class DockerManager:
35
36
  """
36
37
  Manages Docker image building and container execution.
37
- @athena: ef3cc3d7bcbe
38
+ @athena: 1a8a919eb05d
38
39
  """
39
40
 
40
41
  def __init__(self, project_root: Path):
@@ -46,9 +47,12 @@ class DockerManager:
46
47
  @athena: eb7d4c5a27aa
47
48
  """
48
49
  self._project_root = project_root
49
- self._built_images: dict[str, tuple[str, str]] = {} # env_name -> (image_tag, image_id) cache
50
+ self._built_images: dict[
51
+ str, tuple[str, str]
52
+ ] = {} # env_name -> (image_tag, image_id) cache
50
53
 
51
- def _should_add_user_flag(self) -> bool:
54
+ @staticmethod
55
+ def _should_add_user_flag() -> bool:
52
56
  """
53
57
  Check if --user flag should be added to docker run.
54
58
 
@@ -57,7 +61,7 @@ class DockerManager:
57
61
 
58
62
  Returns:
59
63
  True if --user flag should be added, False otherwise
60
- @athena: 6a872eea6a10
64
+ @athena: c5932076dfda
61
65
  """
62
66
  # Skip on Windows - Docker Desktop handles UID mapping differently
63
67
  if platform.system() == "Windows":
@@ -158,7 +162,7 @@ class DockerManager:
158
162
 
159
163
  Raises:
160
164
  DockerError: If docker run fails
161
- @athena: 2c963babb5ca
165
+ @athena: f24fc9c27f81
162
166
  """
163
167
  # Ensure image is built (returns tag and ID)
164
168
  image_tag, image_id = self.ensure_image_built(env)
@@ -197,7 +201,10 @@ class DockerManager:
197
201
 
198
202
  # Add shell and command
199
203
  shell = env.shell or "sh"
200
- docker_cmd.extend([shell, "-c", cmd])
204
+ shell_args = (
205
+ env.args or [] if isinstance(env.args, list) else list(env.args.values())
206
+ )
207
+ docker_cmd.extend([shell, *shell_args, "-c", cmd])
201
208
 
202
209
  # Execute
203
210
  try:
@@ -250,13 +257,14 @@ class DockerManager:
250
257
 
251
258
  return f"{resolved_host_path}:{container_path}"
252
259
 
253
- def _check_docker_available(self) -> None:
260
+ @staticmethod
261
+ def _check_docker_available() -> None:
254
262
  """
255
263
  Check if docker command is available.
256
264
 
257
265
  Raises:
258
266
  DockerError: If docker is not available
259
- @athena: 16ba713e3962
267
+ @athena: 8deaf8c5c05e
260
268
  """
261
269
  try:
262
270
  subprocess.run(
@@ -271,7 +279,8 @@ class DockerManager:
271
279
  "Visit https://docs.docker.com/get-docker/ for installation instructions."
272
280
  )
273
281
 
274
- def _get_image_id(self, image_tag: str) -> str:
282
+ @staticmethod
283
+ def _get_image_id(image_tag: str) -> str:
275
284
  """
276
285
  Get the full image ID for a given tag.
277
286
 
@@ -283,7 +292,7 @@ class DockerManager:
283
292
 
284
293
  Raises:
285
294
  DockerError: If cannot inspect image
286
- @athena: e4bc075fe857
295
+ @athena: 9e5aa77003ee
287
296
  """
288
297
  try:
289
298
  result = subprocess.run(
@@ -312,9 +321,7 @@ def is_docker_environment(env: Environment) -> bool:
312
321
  return bool(env.dockerfile)
313
322
 
314
323
 
315
- def resolve_container_working_dir(
316
- env_working_dir: str, task_working_dir: str
317
- ) -> str:
324
+ def resolve_container_working_dir(env_working_dir: str, task_working_dir: str) -> str:
318
325
  """
319
326
  Resolve working directory inside container.
320
327
 
@@ -345,7 +352,7 @@ def resolve_container_working_dir(
345
352
  return f"/{task_working_dir.lstrip('/')}"
346
353
 
347
354
 
348
- def parse_dockerignore(dockerignore_path: Path) -> "pathspec.PathSpec | None":
355
+ def parse_dockerignore(dockerignore_path: Path) -> PathSpec | None:
349
356
  """
350
357
  Parse .dockerignore file into pathspec matcher.
351
358
 
@@ -432,13 +439,13 @@ def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]
432
439
  Returns:
433
440
  List of (image_reference, digest) tuples where digest may be None for unpinned images
434
441
  Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
435
- @athena: ede7ed483bdd
442
+ @athena: de0d013fdd05
436
443
  """
437
444
  # Regex pattern to match FROM lines
438
445
  # Handles: FROM [--platform=...] image[:tag][@digest] [AS alias]
439
446
  from_pattern = re.compile(
440
447
  r"^\s*FROM\s+" # FROM keyword
441
- r"(?:--platform=[^\s]+\s+)?" # Optional platform flag
448
+ r"(?:--platform=\S+\s+)?" # Optional platform flag
442
449
  r"([^\s@]+)" # Image name (possibly with :tag)
443
450
  r"(?:@(sha256:[a-f0-9]+))?" # Optional @digest
444
451
  r"(?:\s+AS\s+\w+)?" # Optional AS alias