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.
- {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/test.yml +9 -16
- {tasktree-0.0.21 → tasktree-0.0.22}/CLAUDE.md +2 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/PKG-INFO +13 -15
- {tasktree-0.0.21 → tasktree-0.0.22}/README.md +10 -14
- {tasktree-0.0.21 → tasktree-0.0.22}/pyproject.toml +5 -4
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/cli.py +91 -31
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/docker.py +24 -17
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/executor.py +263 -211
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/graph.py +15 -10
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/hasher.py +13 -6
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/parser.py +220 -121
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/state.py +7 -8
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/substitution.py +27 -15
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/types.py +29 -12
- {tasktree-0.0.21 → tasktree-0.0.22}/tasktree.yaml +29 -26
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/__init__.py +5 -1
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_docker_basic.py +7 -8
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_docker_environment.py +9 -5
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_docker_ownership.py +6 -6
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_docker_volumes.py +8 -13
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/e2e/test_non_docker.py +8 -12
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_arg_choices.py +23 -9
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_arg_min_max.py +5 -3
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_builtin_variables.py +103 -56
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_clean_state.py +2 -2
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_cli_options.py +45 -46
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_dependency_execution.py +18 -16
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_dependency_outputs.py +14 -28
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_end_to_end.py +55 -9
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_exported_args.py +27 -48
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_input_detection.py +3 -3
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_missing_outputs.py +11 -2
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_nested_imports.py +28 -10
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_parameterized_deps_execution.py +2 -2
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_parameterized_deps_templates.py +10 -8
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_private_tasks_execution.py +14 -6
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_self_references.py +165 -53
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_state_persistence.py +2 -2
- tasktree-0.0.22/tests/integration/test_unified_execution.py +215 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_working_directory.py +15 -5
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_cli.py +41 -39
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_dependency_parsing.py +0 -2
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_docker.py +73 -27
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_environment_tracking.py +35 -23
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_executor.py +588 -214
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_graph.py +62 -22
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_hasher.py +1 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_list_formatting.py +61 -47
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_parser.py +384 -418
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_state.py +6 -4
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_substitution.py +105 -54
- {tasktree-0.0.21 → tasktree-0.0.22}/uv.lock +126 -3
- {tasktree-0.0.21 → tasktree-0.0.22}/.claude/settings.local.json +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/claude-code-review.yml +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/claude.yml +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/release.yml +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/.github/workflows/validate-pipx-install.yml +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/.gitignore +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/.python-version +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/__init__.py +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/example/source.txt +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/example/tasktree.yaml +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/schema/README.md +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/schema/tasktree-schema.json +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/schema/vscode-settings-snippet.json +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/src/__init__.py +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/src/tasktree/__init__.py +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_docker_build_args.py +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_parameterized_dependencies.yaml +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/integration/test_variables.py +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_parameterized_graph.py +0 -0
- {tasktree-0.0.21 → tasktree-0.0.22}/tests/unit/test_private_tasks.py +0 -0
- {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
|
-
|
|
54
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
343
|
-
- **Windows**: cmd
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
328
|
-
- **Windows**: cmd
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
206
|
-
return dumper.represent_scalar(
|
|
207
|
-
return dumper.represent_scalar(
|
|
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(
|
|
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(
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
618
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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[
|
|
50
|
+
self._built_images: dict[
|
|
51
|
+
str, tuple[str, str]
|
|
52
|
+
] = {} # env_name -> (image_tag, image_id) cache
|
|
50
53
|
|
|
51
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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) ->
|
|
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:
|
|
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
|
|
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
|