tasktree 0.0.5__tar.gz → 0.0.6__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.5 → tasktree-0.0.6}/PKG-INFO +32 -1
- {tasktree-0.0.5 → tasktree-0.0.6}/README.md +31 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/pyproject.toml +1 -1
- tasktree-0.0.6/schema/README.md +118 -0
- tasktree-0.0.6/schema/tasktree-schema.json +163 -0
- tasktree-0.0.6/schema/vscode-settings-snippet.json +10 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/cli.py +66 -31
- {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/parser.py +44 -7
- {tasktree-0.0.5 → tasktree-0.0.6}/tasktree.yaml +2 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_cli_options.py +222 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_working_directory.py +41 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_parser.py +48 -3
- {tasktree-0.0.5 → tasktree-0.0.6}/.github/workflows/release.yml +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/.github/workflows/test.yml +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/.gitignore +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/.python-version +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/CLAUDE.md +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/example/source.txt +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/example/tasktree.yaml +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/requirements/future/docker-task-environments.md +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/requirements/implemented/shell-environment-requirements.md +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/src/__init__.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/__init__.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/executor.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/graph.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/hasher.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/state.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/tasks.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/src/tasktree/types.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_clean_state.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_dependency_execution.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_end_to_end.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_input_detection.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_missing_outputs.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_nested_imports.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/integration/test_state_persistence.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_cli.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_executor.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_graph.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_hasher.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_state.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_tasks.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/tests/unit/test_types.py +0 -0
- {tasktree-0.0.5 → tasktree-0.0.6}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasktree
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.6
|
|
4
4
|
Summary: A task automation tool with incremental execution
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: click>=8.1.0
|
|
@@ -188,6 +188,37 @@ cd tasktree
|
|
|
188
188
|
pipx install .
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
+
## Editor Support
|
|
192
|
+
|
|
193
|
+
Task Tree includes a [JSON Schema](schema/tasktree-schema.json) that provides autocomplete, validation, and documentation in modern editors.
|
|
194
|
+
|
|
195
|
+
### VS Code
|
|
196
|
+
|
|
197
|
+
Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then add to your workspace `.vscode/settings.json`:
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{
|
|
201
|
+
"yaml.schemas": {
|
|
202
|
+
"https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
|
|
203
|
+
"tasktree.yaml",
|
|
204
|
+
"tt.yaml"
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Or add a comment at the top of your `tasktree.yaml`:
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
|
|
214
|
+
|
|
215
|
+
tasks:
|
|
216
|
+
build:
|
|
217
|
+
cmd: cargo build
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
See [schema/README.md](schema/README.md) for IntelliJ/PyCharm and command-line validation.
|
|
221
|
+
|
|
191
222
|
## Quick Start
|
|
192
223
|
|
|
193
224
|
Create a `tasktree.yaml` (or `tt.yaml`) in your project:
|
|
@@ -174,6 +174,37 @@ cd tasktree
|
|
|
174
174
|
pipx install .
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
+
## Editor Support
|
|
178
|
+
|
|
179
|
+
Task Tree includes a [JSON Schema](schema/tasktree-schema.json) that provides autocomplete, validation, and documentation in modern editors.
|
|
180
|
+
|
|
181
|
+
### VS Code
|
|
182
|
+
|
|
183
|
+
Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then add to your workspace `.vscode/settings.json`:
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"yaml.schemas": {
|
|
188
|
+
"https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
|
|
189
|
+
"tasktree.yaml",
|
|
190
|
+
"tt.yaml"
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Or add a comment at the top of your `tasktree.yaml`:
|
|
197
|
+
|
|
198
|
+
```yaml
|
|
199
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
|
|
200
|
+
|
|
201
|
+
tasks:
|
|
202
|
+
build:
|
|
203
|
+
cmd: cargo build
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
See [schema/README.md](schema/README.md) for IntelliJ/PyCharm and command-line validation.
|
|
207
|
+
|
|
177
208
|
## Quick Start
|
|
178
209
|
|
|
179
210
|
Create a `tasktree.yaml` (or `tt.yaml`) in your project:
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Task Tree YAML Schema
|
|
2
|
+
|
|
3
|
+
This directory contains the JSON Schema for Task Tree recipe files (`tasktree.yaml` or `tt.yaml`).
|
|
4
|
+
|
|
5
|
+
## What is a YAML Schema?
|
|
6
|
+
|
|
7
|
+
The JSON Schema provides:
|
|
8
|
+
- **Autocomplete**: Get suggestions for task fields as you type
|
|
9
|
+
- **Validation**: Immediate feedback on syntax errors
|
|
10
|
+
- **Documentation**: Hover over fields to see descriptions
|
|
11
|
+
- **Type checking**: Ensure values match expected types
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### VS Code
|
|
16
|
+
|
|
17
|
+
For your project, copy the settings from `schema/vscode-settings-snippet.json` to your `.vscode/settings.json`:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"yaml.schemas": {
|
|
22
|
+
"https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
|
|
23
|
+
"tasktree.yaml",
|
|
24
|
+
"tt.yaml"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or add a comment at the top of your `tasktree.yaml`:
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
|
|
34
|
+
|
|
35
|
+
tasks:
|
|
36
|
+
build:
|
|
37
|
+
cmd: cargo build
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### IntelliJ / PyCharm
|
|
41
|
+
|
|
42
|
+
1. Go to **Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings**
|
|
43
|
+
2. Add new mapping:
|
|
44
|
+
- **Name**: Task Tree
|
|
45
|
+
- **Schema file**: Point to `schema/tasktree-schema.json`
|
|
46
|
+
- **Schema version**: JSON Schema version 7
|
|
47
|
+
- **File path pattern**: `tasktree.yaml` or `tt.yaml`
|
|
48
|
+
|
|
49
|
+
### Command Line Validation
|
|
50
|
+
|
|
51
|
+
You can validate your recipe files using tools like `check-jsonschema`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Install
|
|
55
|
+
pip install check-jsonschema
|
|
56
|
+
|
|
57
|
+
# Validate
|
|
58
|
+
check-jsonschema --schemafile schema/tasktree-schema.json tasktree.yaml
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Schema Features
|
|
62
|
+
|
|
63
|
+
The schema validates:
|
|
64
|
+
|
|
65
|
+
- **Top-level structure**: Only `imports`, `environments`, and `tasks` are allowed at root
|
|
66
|
+
- **Required fields**: Tasks must have a `cmd` field
|
|
67
|
+
- **Field types**: Ensures strings, arrays, and objects are used correctly
|
|
68
|
+
- **Naming patterns**: Task names and namespaces must match `^[a-zA-Z][a-zA-Z0-9_-]*$`
|
|
69
|
+
- **Environment requirements**: Environments must specify a `shell`
|
|
70
|
+
|
|
71
|
+
## Example
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
imports:
|
|
75
|
+
- file: common/base.yaml
|
|
76
|
+
as: base
|
|
77
|
+
|
|
78
|
+
environments:
|
|
79
|
+
default: bash-strict
|
|
80
|
+
bash-strict:
|
|
81
|
+
shell: /bin/bash
|
|
82
|
+
args: ['-e', '-u', '-o', 'pipefail']
|
|
83
|
+
|
|
84
|
+
tasks:
|
|
85
|
+
build:
|
|
86
|
+
desc: Build the application
|
|
87
|
+
deps: [base.setup]
|
|
88
|
+
inputs: ["src/**/*.rs"]
|
|
89
|
+
outputs: [target/release/bin]
|
|
90
|
+
cmd: cargo build --release
|
|
91
|
+
|
|
92
|
+
test:
|
|
93
|
+
desc: Run tests
|
|
94
|
+
deps: [build]
|
|
95
|
+
cmd: cargo test
|
|
96
|
+
|
|
97
|
+
deploy:
|
|
98
|
+
desc: Deploy to environment
|
|
99
|
+
deps: [build]
|
|
100
|
+
args: [environment, region=us-west-1]
|
|
101
|
+
cmd: |
|
|
102
|
+
echo "Deploying to {{environment}} in {{region}}"
|
|
103
|
+
./deploy.sh {{environment}} {{region}}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
If you find issues with the schema or want to improve it, please:
|
|
109
|
+
|
|
110
|
+
1. Update `tasktree-schema.json`
|
|
111
|
+
2. Test with your editor
|
|
112
|
+
3. Submit a pull request
|
|
113
|
+
|
|
114
|
+
## References
|
|
115
|
+
|
|
116
|
+
- [JSON Schema Specification](https://json-schema.org/)
|
|
117
|
+
- [VS Code YAML Extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml)
|
|
118
|
+
- [YAML Language Server](https://github.com/redhat-developer/yaml-language-server)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/kevinchannon/tasktree/schema/tasktree-schema.json",
|
|
4
|
+
"title": "Task Tree Recipe Schema",
|
|
5
|
+
"description": "Schema for Task Tree (tt) recipe files (tasktree.yaml or tt.yaml)",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"imports": {
|
|
9
|
+
"description": "Import task definitions from other files",
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"properties": {
|
|
14
|
+
"file": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "Path to the YAML file to import (relative to current file)"
|
|
17
|
+
},
|
|
18
|
+
"as": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Namespace to use for imported tasks",
|
|
21
|
+
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"required": ["file", "as"],
|
|
25
|
+
"additionalProperties": false
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"environments": {
|
|
29
|
+
"description": "Shell execution environment configurations",
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"default": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Name of the default environment to use"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"patternProperties": {
|
|
38
|
+
"^[a-zA-Z][a-zA-Z0-9_-]*$": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"shell": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Path to the shell executable (e.g., /bin/bash, python3, pwsh)"
|
|
44
|
+
},
|
|
45
|
+
"args": {
|
|
46
|
+
"description": "Arguments to pass to the shell",
|
|
47
|
+
"oneOf": [
|
|
48
|
+
{
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "string"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"type": "string"
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"preamble": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "Code to execute before the command (e.g., imports, setup)"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"required": ["shell"],
|
|
65
|
+
"additionalProperties": false
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"additionalProperties": false
|
|
69
|
+
},
|
|
70
|
+
"tasks": {
|
|
71
|
+
"description": "Task definitions",
|
|
72
|
+
"type": "object",
|
|
73
|
+
"patternProperties": {
|
|
74
|
+
"^[a-zA-Z][a-zA-Z0-9_-]*$": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"desc": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Human-readable description of the task"
|
|
80
|
+
},
|
|
81
|
+
"deps": {
|
|
82
|
+
"description": "Task dependencies (must run before this task)",
|
|
83
|
+
"oneOf": [
|
|
84
|
+
{
|
|
85
|
+
"type": "array",
|
|
86
|
+
"items": {
|
|
87
|
+
"type": "string"
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"type": "string"
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
"inputs": {
|
|
96
|
+
"description": "Input file patterns (glob supported). Task re-runs if inputs change.",
|
|
97
|
+
"oneOf": [
|
|
98
|
+
{
|
|
99
|
+
"type": "array",
|
|
100
|
+
"items": {
|
|
101
|
+
"type": "string"
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"type": "string"
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
"outputs": {
|
|
110
|
+
"description": "Output file patterns (glob supported). Task re-runs if outputs missing.",
|
|
111
|
+
"oneOf": [
|
|
112
|
+
{
|
|
113
|
+
"type": "array",
|
|
114
|
+
"items": {
|
|
115
|
+
"type": "string"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"type": "string"
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
},
|
|
123
|
+
"working_dir": {
|
|
124
|
+
"type": "string",
|
|
125
|
+
"description": "Directory to execute the command in (relative to recipe file)"
|
|
126
|
+
},
|
|
127
|
+
"args": {
|
|
128
|
+
"description": "Parameterized arguments for the task (format: name, name:type, name:type=default)",
|
|
129
|
+
"oneOf": [
|
|
130
|
+
{
|
|
131
|
+
"type": "array",
|
|
132
|
+
"items": {
|
|
133
|
+
"type": "string"
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"type": "string"
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
"env": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"description": "Environment to use for this task (overrides default)"
|
|
144
|
+
},
|
|
145
|
+
"cmd": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"description": "Shell command to execute. Use {{arg}} for parameter substitution."
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"required": ["cmd"],
|
|
151
|
+
"additionalProperties": false
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"additionalProperties": false
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"additionalProperties": false,
|
|
158
|
+
"anyOf": [
|
|
159
|
+
{ "required": ["tasks"] },
|
|
160
|
+
{ "required": ["imports"] },
|
|
161
|
+
{ "required": ["environments"] }
|
|
162
|
+
]
|
|
163
|
+
}
|
|
@@ -26,11 +26,11 @@ app = typer.Typer(
|
|
|
26
26
|
console = Console()
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def _list_tasks():
|
|
29
|
+
def _list_tasks(tasks_file: Optional[str] = None):
|
|
30
30
|
"""List all available tasks with descriptions."""
|
|
31
|
-
recipe = _get_recipe()
|
|
31
|
+
recipe = _get_recipe(tasks_file)
|
|
32
32
|
if recipe is None:
|
|
33
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
33
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
34
34
|
raise typer.Exit(1)
|
|
35
35
|
|
|
36
36
|
table = Table(title="Available Tasks")
|
|
@@ -45,11 +45,11 @@ def _list_tasks():
|
|
|
45
45
|
console.print(table)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def _show_task(task_name: str):
|
|
48
|
+
def _show_task(task_name: str, tasks_file: Optional[str] = None):
|
|
49
49
|
"""Show task definition with syntax highlighting."""
|
|
50
|
-
recipe = _get_recipe()
|
|
50
|
+
recipe = _get_recipe(tasks_file)
|
|
51
51
|
if recipe is None:
|
|
52
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
52
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
53
53
|
raise typer.Exit(1)
|
|
54
54
|
|
|
55
55
|
task = recipe.get_task(task_name)
|
|
@@ -79,17 +79,26 @@ def _show_task(task_name: str):
|
|
|
79
79
|
task_dict = task_yaml[task_name]
|
|
80
80
|
task_yaml[task_name] = {k: v for k, v in task_dict.items() if v}
|
|
81
81
|
|
|
82
|
+
# Configure YAML dumper to use literal block style for multiline strings
|
|
83
|
+
def literal_presenter(dumper, data):
|
|
84
|
+
"""Use literal block style (|) for strings containing newlines."""
|
|
85
|
+
if '\n' in data:
|
|
86
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
|
|
87
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', data)
|
|
88
|
+
|
|
89
|
+
yaml.add_representer(str, literal_presenter)
|
|
90
|
+
|
|
82
91
|
# Format and highlight using Rich
|
|
83
92
|
yaml_str = yaml.dump(task_yaml, default_flow_style=False, sort_keys=False)
|
|
84
93
|
syntax = Syntax(yaml_str, "yaml", theme="ansi_light", line_numbers=False)
|
|
85
94
|
console.print(syntax)
|
|
86
95
|
|
|
87
96
|
|
|
88
|
-
def _show_tree(task_name: str):
|
|
97
|
+
def _show_tree(task_name: str, tasks_file: Optional[str] = None):
|
|
89
98
|
"""Show dependency tree structure."""
|
|
90
|
-
recipe = _get_recipe()
|
|
99
|
+
recipe = _get_recipe(tasks_file)
|
|
91
100
|
if recipe is None:
|
|
92
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
101
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
93
102
|
raise typer.Exit(1)
|
|
94
103
|
|
|
95
104
|
task = recipe.get_task(task_name)
|
|
@@ -169,6 +178,7 @@ def main(
|
|
|
169
178
|
list_opt: Optional[bool] = typer.Option(None, "--list", "-l", help="List all available tasks"),
|
|
170
179
|
show: Optional[str] = typer.Option(None, "--show", "-s", help="Show task definition"),
|
|
171
180
|
tree: Optional[str] = typer.Option(None, "--tree", "-t", help="Show dependency tree"),
|
|
181
|
+
tasks_file: Optional[str] = typer.Option(None, "--tasks", "-T", help="Path to recipe file (tasktree.yaml, *.tasks, etc.)"),
|
|
172
182
|
init: Optional[bool] = typer.Option(
|
|
173
183
|
None, "--init", "-i", help="Create a blank tasktree.yaml"
|
|
174
184
|
),
|
|
@@ -208,15 +218,15 @@ def main(
|
|
|
208
218
|
"""
|
|
209
219
|
|
|
210
220
|
if list_opt:
|
|
211
|
-
_list_tasks()
|
|
221
|
+
_list_tasks(tasks_file)
|
|
212
222
|
raise typer.Exit()
|
|
213
223
|
|
|
214
224
|
if show:
|
|
215
|
-
_show_task(show)
|
|
225
|
+
_show_task(show, tasks_file)
|
|
216
226
|
raise typer.Exit()
|
|
217
227
|
|
|
218
228
|
if tree:
|
|
219
|
-
_show_tree(tree)
|
|
229
|
+
_show_tree(tree, tasks_file)
|
|
220
230
|
raise typer.Exit()
|
|
221
231
|
|
|
222
232
|
if init:
|
|
@@ -224,17 +234,17 @@ def main(
|
|
|
224
234
|
raise typer.Exit()
|
|
225
235
|
|
|
226
236
|
if clean or clean_state or reset:
|
|
227
|
-
_clean_state()
|
|
237
|
+
_clean_state(tasks_file)
|
|
228
238
|
raise typer.Exit()
|
|
229
239
|
|
|
230
240
|
if task_args:
|
|
231
241
|
# --only implies --force
|
|
232
242
|
force_execution = force or only or False
|
|
233
|
-
_execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env)
|
|
243
|
+
_execute_dynamic_task(task_args, force=force_execution, only=only or False, env=env, tasks_file=tasks_file)
|
|
234
244
|
else:
|
|
235
|
-
recipe = _get_recipe()
|
|
245
|
+
recipe = _get_recipe(tasks_file)
|
|
236
246
|
if recipe is None:
|
|
237
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
247
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
238
248
|
console.print("Run [cyan]tt --init[/cyan] to create a blank recipe file")
|
|
239
249
|
raise typer.Exit(1)
|
|
240
250
|
|
|
@@ -245,13 +255,19 @@ def main(
|
|
|
245
255
|
console.print("Use [cyan]tt <task-name>[/cyan] to run a task")
|
|
246
256
|
|
|
247
257
|
|
|
248
|
-
def _clean_state() -> None:
|
|
258
|
+
def _clean_state(tasks_file: Optional[str] = None) -> None:
|
|
249
259
|
"""Remove the .tasktree-state file to reset task execution state."""
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
260
|
+
if tasks_file:
|
|
261
|
+
recipe_path = Path(tasks_file)
|
|
262
|
+
if not recipe_path.exists():
|
|
263
|
+
console.print(f"[red]Recipe file not found: {tasks_file}[/red]")
|
|
264
|
+
raise typer.Exit(1)
|
|
265
|
+
else:
|
|
266
|
+
recipe_path = find_recipe_file()
|
|
267
|
+
if recipe_path is None:
|
|
268
|
+
console.print("[yellow]No recipe file found[/yellow]")
|
|
269
|
+
console.print("State file location depends on recipe file location")
|
|
270
|
+
raise typer.Exit(1)
|
|
255
271
|
|
|
256
272
|
project_root = recipe_path.parent
|
|
257
273
|
state_path = project_root / ".tasktree-state"
|
|
@@ -264,29 +280,48 @@ def _clean_state() -> None:
|
|
|
264
280
|
console.print(f"[yellow]No state file found at {state_path}[/yellow]")
|
|
265
281
|
|
|
266
282
|
|
|
267
|
-
def _get_recipe() -> Optional[Recipe]:
|
|
268
|
-
"""Get parsed recipe or None if not found.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
283
|
+
def _get_recipe(recipe_file: Optional[str] = None) -> Optional[Recipe]:
|
|
284
|
+
"""Get parsed recipe or None if not found.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
|
|
288
|
+
"""
|
|
289
|
+
if recipe_file:
|
|
290
|
+
recipe_path = Path(recipe_file)
|
|
291
|
+
if not recipe_path.exists():
|
|
292
|
+
console.print(f"[red]Recipe file not found: {recipe_file}[/red]")
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
# When explicitly specified, project root is current working directory
|
|
295
|
+
project_root = Path.cwd()
|
|
296
|
+
else:
|
|
297
|
+
try:
|
|
298
|
+
recipe_path = find_recipe_file()
|
|
299
|
+
if recipe_path is None:
|
|
300
|
+
return None
|
|
301
|
+
except ValueError as e:
|
|
302
|
+
# Multiple recipe files found
|
|
303
|
+
console.print(f"[red]{e}[/red]")
|
|
304
|
+
raise typer.Exit(1)
|
|
305
|
+
# When auto-discovered, project root is recipe file's parent
|
|
306
|
+
project_root = None
|
|
272
307
|
|
|
273
308
|
try:
|
|
274
|
-
return parse_recipe(recipe_path)
|
|
309
|
+
return parse_recipe(recipe_path, project_root)
|
|
275
310
|
except Exception as e:
|
|
276
311
|
console.print(f"[red]Error parsing recipe: {e}[/red]")
|
|
277
312
|
raise typer.Exit(1)
|
|
278
313
|
|
|
279
314
|
|
|
280
|
-
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None) -> None:
|
|
315
|
+
def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = False, env: Optional[str] = None, tasks_file: Optional[str] = None) -> None:
|
|
281
316
|
if not args:
|
|
282
317
|
return
|
|
283
318
|
|
|
284
319
|
task_name = args[0]
|
|
285
320
|
task_args = args[1:]
|
|
286
321
|
|
|
287
|
-
recipe = _get_recipe()
|
|
322
|
+
recipe = _get_recipe(tasks_file)
|
|
288
323
|
if recipe is None:
|
|
289
|
-
console.print("[red]No recipe file found (tasktree.yaml
|
|
324
|
+
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
290
325
|
raise typer.Exit(1)
|
|
291
326
|
|
|
292
327
|
# Apply global environment override if provided
|
|
@@ -94,13 +94,25 @@ class Recipe:
|
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
97
|
-
"""Find recipe file
|
|
97
|
+
"""Find recipe file in current or parent directories.
|
|
98
|
+
|
|
99
|
+
Looks for recipe files matching these patterns (in order of preference):
|
|
100
|
+
- tasktree.yaml
|
|
101
|
+
- tasktree.yml
|
|
102
|
+
- tt.yaml
|
|
103
|
+
- *.tasks
|
|
104
|
+
|
|
105
|
+
If multiple recipe files are found in the same directory, raises ValueError
|
|
106
|
+
with instructions to use --tasks option.
|
|
98
107
|
|
|
99
108
|
Args:
|
|
100
109
|
start_dir: Directory to start searching from (defaults to cwd)
|
|
101
110
|
|
|
102
111
|
Returns:
|
|
103
112
|
Path to recipe file if found, None otherwise
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: If multiple recipe files found in the same directory
|
|
104
116
|
"""
|
|
105
117
|
if start_dir is None:
|
|
106
118
|
start_dir = Path.cwd()
|
|
@@ -109,10 +121,30 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
|
109
121
|
|
|
110
122
|
# Search up the directory tree
|
|
111
123
|
while True:
|
|
112
|
-
|
|
124
|
+
candidates = []
|
|
125
|
+
|
|
126
|
+
# Check for exact filenames first
|
|
127
|
+
for filename in ["tasktree.yaml", "tasktree.yml", "tt.yaml"]:
|
|
113
128
|
recipe_path = current / filename
|
|
114
129
|
if recipe_path.exists():
|
|
115
|
-
|
|
130
|
+
candidates.append(recipe_path)
|
|
131
|
+
|
|
132
|
+
# Check for *.tasks files
|
|
133
|
+
for tasks_file in current.glob("*.tasks"):
|
|
134
|
+
if tasks_file.is_file():
|
|
135
|
+
candidates.append(tasks_file)
|
|
136
|
+
|
|
137
|
+
if len(candidates) > 1:
|
|
138
|
+
# Multiple recipe files found - ambiguous
|
|
139
|
+
filenames = [c.name for c in candidates]
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Multiple recipe files found in {current}:\n"
|
|
142
|
+
f" {', '.join(filenames)}\n\n"
|
|
143
|
+
f"Please specify which file to use with --tasks (-T):\n"
|
|
144
|
+
f" tt --tasks {filenames[0]} <task-name>"
|
|
145
|
+
)
|
|
146
|
+
elif len(candidates) == 1:
|
|
147
|
+
return candidates[0]
|
|
116
148
|
|
|
117
149
|
# Move to parent directory
|
|
118
150
|
parent = current.parent
|
|
@@ -186,11 +218,13 @@ def _parse_file_with_env(
|
|
|
186
218
|
return tasks, environments, default_env
|
|
187
219
|
|
|
188
220
|
|
|
189
|
-
def parse_recipe(recipe_path: Path) -> Recipe:
|
|
221
|
+
def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
|
|
190
222
|
"""Parse a recipe file and handle imports recursively.
|
|
191
223
|
|
|
192
224
|
Args:
|
|
193
225
|
recipe_path: Path to the main recipe file
|
|
226
|
+
project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
|
|
227
|
+
When using --tasks option, this should be the current working directory.
|
|
194
228
|
|
|
195
229
|
Returns:
|
|
196
230
|
Recipe object with all tasks (including recursively imported tasks)
|
|
@@ -204,7 +238,9 @@ def parse_recipe(recipe_path: Path) -> Recipe:
|
|
|
204
238
|
if not recipe_path.exists():
|
|
205
239
|
raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
|
|
206
240
|
|
|
207
|
-
|
|
241
|
+
# Default project root to recipe file's parent if not specified
|
|
242
|
+
if project_root is None:
|
|
243
|
+
project_root = recipe_path.parent
|
|
208
244
|
|
|
209
245
|
# Parse main file - it will recursively handle all imports
|
|
210
246
|
tasks, environments, default_env = _parse_file_with_env(
|
|
@@ -263,8 +299,9 @@ def _parse_file(
|
|
|
263
299
|
tasks: dict[str, Task] = {}
|
|
264
300
|
file_dir = file_path.parent
|
|
265
301
|
|
|
266
|
-
# Default working directory is the
|
|
267
|
-
|
|
302
|
+
# Default working directory is the project root (where tt is invoked)
|
|
303
|
+
# NOT the directory where the tasks file is located
|
|
304
|
+
default_working_dir = "."
|
|
268
305
|
|
|
269
306
|
# Track local import namespaces for dependency rewriting
|
|
270
307
|
local_import_namespaces: set[str] = set()
|
|
@@ -209,6 +209,69 @@ tasks:
|
|
|
209
209
|
os.chdir(original_cwd)
|
|
210
210
|
|
|
211
211
|
|
|
212
|
+
class TestShowOption(unittest.TestCase):
|
|
213
|
+
"""Test the --show option displays task definitions correctly."""
|
|
214
|
+
|
|
215
|
+
def setUp(self):
|
|
216
|
+
"""Set up test fixtures."""
|
|
217
|
+
self.runner = CliRunner()
|
|
218
|
+
self.env = {"NO_COLOR": "1"}
|
|
219
|
+
|
|
220
|
+
def test_show_multiline_command_preserves_newlines(self):
|
|
221
|
+
"""Test that --show displays multiline commands with proper newlines, not escaped \\n."""
|
|
222
|
+
with self.runner.isolated_filesystem():
|
|
223
|
+
recipe_file = Path("tasktree.yaml")
|
|
224
|
+
recipe_file.write_text(
|
|
225
|
+
"""
|
|
226
|
+
tasks:
|
|
227
|
+
multiline:
|
|
228
|
+
desc: Task with multiline command
|
|
229
|
+
cmd: |
|
|
230
|
+
echo "Line 1"
|
|
231
|
+
echo "Line 2"
|
|
232
|
+
echo "Line 3"
|
|
233
|
+
"""
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
result = self.runner.invoke(app, ["--show", "multiline"], env=self.env)
|
|
237
|
+
|
|
238
|
+
self.assertEqual(result.exit_code, 0)
|
|
239
|
+
|
|
240
|
+
# Should show the literal block style indicator
|
|
241
|
+
self.assertIn("cmd: |", result.stdout)
|
|
242
|
+
|
|
243
|
+
# Should show each line on a separate line (not escaped \\n)
|
|
244
|
+
self.assertIn('echo "Line 1"', result.stdout)
|
|
245
|
+
self.assertIn('echo "Line 2"', result.stdout)
|
|
246
|
+
self.assertIn('echo "Line 3"', result.stdout)
|
|
247
|
+
|
|
248
|
+
# Should NOT show escaped newlines
|
|
249
|
+
self.assertNotIn("\\n", result.stdout)
|
|
250
|
+
|
|
251
|
+
def test_show_single_line_command(self):
|
|
252
|
+
"""Test that --show displays single-line commands cleanly."""
|
|
253
|
+
with self.runner.isolated_filesystem():
|
|
254
|
+
recipe_file = Path("tasktree.yaml")
|
|
255
|
+
recipe_file.write_text(
|
|
256
|
+
"""
|
|
257
|
+
tasks:
|
|
258
|
+
single:
|
|
259
|
+
desc: Task with single line command
|
|
260
|
+
cmd: echo "Hello world"
|
|
261
|
+
"""
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
result = self.runner.invoke(app, ["--show", "single"], env=self.env)
|
|
265
|
+
|
|
266
|
+
self.assertEqual(result.exit_code, 0)
|
|
267
|
+
|
|
268
|
+
# Should show the command on a single line
|
|
269
|
+
self.assertIn('cmd: echo "Hello world"', result.stdout)
|
|
270
|
+
|
|
271
|
+
# Should NOT use literal block style for single-line commands
|
|
272
|
+
self.assertNotIn("cmd: |", result.stdout)
|
|
273
|
+
|
|
274
|
+
|
|
212
275
|
class TestForceOption(unittest.TestCase):
|
|
213
276
|
"""Test the --force/-f option forces re-run of all tasks."""
|
|
214
277
|
|
|
@@ -649,5 +712,164 @@ tasks:
|
|
|
649
712
|
os.chdir(original_cwd)
|
|
650
713
|
|
|
651
714
|
|
|
715
|
+
class TestTasksFileOption(unittest.TestCase):
|
|
716
|
+
"""Test the --tasks/-T option for specifying recipe files."""
|
|
717
|
+
|
|
718
|
+
def setUp(self):
|
|
719
|
+
"""Set up test fixtures."""
|
|
720
|
+
self.runner = CliRunner()
|
|
721
|
+
self.env = {"NO_COLOR": "1"}
|
|
722
|
+
|
|
723
|
+
def test_tasks_option_with_yml_extension(self):
|
|
724
|
+
"""Test --tasks option works with .yml extension."""
|
|
725
|
+
with self.runner.isolated_filesystem():
|
|
726
|
+
recipe_file = Path("tasktree.yml")
|
|
727
|
+
recipe_file.write_text(
|
|
728
|
+
"""
|
|
729
|
+
tasks:
|
|
730
|
+
build:
|
|
731
|
+
desc: Build with yml
|
|
732
|
+
cmd: echo "Building from yml"
|
|
733
|
+
"""
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
result = self.runner.invoke(app, ["--tasks", "tasktree.yml", "build"], env=self.env)
|
|
737
|
+
|
|
738
|
+
self.assertEqual(result.exit_code, 0)
|
|
739
|
+
self.assertIn("build", result.stdout)
|
|
740
|
+
self.assertIn("completed successfully", result.stdout)
|
|
741
|
+
|
|
742
|
+
def test_tasks_option_with_tasks_extension(self):
|
|
743
|
+
"""Test --tasks option works with .tasks extension."""
|
|
744
|
+
with self.runner.isolated_filesystem():
|
|
745
|
+
recipe_file = Path("build.tasks")
|
|
746
|
+
recipe_file.write_text(
|
|
747
|
+
"""
|
|
748
|
+
tasks:
|
|
749
|
+
compile:
|
|
750
|
+
desc: Compile code
|
|
751
|
+
cmd: echo "Compiling"
|
|
752
|
+
"""
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
result = self.runner.invoke(app, ["--tasks", "build.tasks", "compile"], env=self.env)
|
|
756
|
+
|
|
757
|
+
self.assertEqual(result.exit_code, 0)
|
|
758
|
+
self.assertIn("compile", result.stdout)
|
|
759
|
+
self.assertIn("completed successfully", result.stdout)
|
|
760
|
+
|
|
761
|
+
def test_tasks_option_with_short_flag(self):
|
|
762
|
+
"""Test -T short flag works."""
|
|
763
|
+
with self.runner.isolated_filesystem():
|
|
764
|
+
recipe_file = Path("my.tasks")
|
|
765
|
+
recipe_file.write_text(
|
|
766
|
+
"""
|
|
767
|
+
tasks:
|
|
768
|
+
test:
|
|
769
|
+
cmd: echo "Testing"
|
|
770
|
+
"""
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
result = self.runner.invoke(app, ["-T", "my.tasks", "test"], env=self.env)
|
|
774
|
+
|
|
775
|
+
self.assertEqual(result.exit_code, 0)
|
|
776
|
+
self.assertIn("test", result.stdout)
|
|
777
|
+
self.assertIn("completed successfully", result.stdout)
|
|
778
|
+
|
|
779
|
+
def test_multiple_recipe_files_without_tasks_option_fails(self):
|
|
780
|
+
"""Test that having multiple recipe files without --tasks raises error."""
|
|
781
|
+
with self.runner.isolated_filesystem():
|
|
782
|
+
# Create multiple recipe files
|
|
783
|
+
Path("tasktree.yaml").write_text("tasks:\n build:\n cmd: echo yaml")
|
|
784
|
+
Path("tasktree.yml").write_text("tasks:\n build:\n cmd: echo yml")
|
|
785
|
+
|
|
786
|
+
# Should fail with helpful error message
|
|
787
|
+
result = self.runner.invoke(app, ["build"], env=self.env)
|
|
788
|
+
|
|
789
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
790
|
+
self.assertIn("Multiple recipe files found", result.stdout)
|
|
791
|
+
self.assertIn("--tasks", result.stdout)
|
|
792
|
+
|
|
793
|
+
def test_tasks_option_selects_specific_file_when_multiple_exist(self):
|
|
794
|
+
"""Test --tasks option selects specific file when multiple exist."""
|
|
795
|
+
with self.runner.isolated_filesystem():
|
|
796
|
+
# Create multiple recipe files with different task names
|
|
797
|
+
Path("tasktree.yaml").write_text(
|
|
798
|
+
"""
|
|
799
|
+
tasks:
|
|
800
|
+
yaml-task:
|
|
801
|
+
cmd: echo "From yaml"
|
|
802
|
+
"""
|
|
803
|
+
)
|
|
804
|
+
Path("build.tasks").write_text(
|
|
805
|
+
"""
|
|
806
|
+
tasks:
|
|
807
|
+
tasks-task:
|
|
808
|
+
cmd: echo "From tasks"
|
|
809
|
+
"""
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
# Use --tasks to select the .tasks file - should be able to run tasks-task
|
|
813
|
+
result = self.runner.invoke(app, ["--tasks", "build.tasks", "tasks-task"], env=self.env)
|
|
814
|
+
self.assertEqual(result.exit_code, 0)
|
|
815
|
+
self.assertIn("tasks-task", result.stdout)
|
|
816
|
+
|
|
817
|
+
# Should not be able to run yaml-task from build.tasks
|
|
818
|
+
result = self.runner.invoke(app, ["--tasks", "build.tasks", "yaml-task"], env=self.env)
|
|
819
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
820
|
+
self.assertIn("Task not found", result.stdout)
|
|
821
|
+
|
|
822
|
+
def test_tasks_option_with_list(self):
|
|
823
|
+
"""Test --tasks option works with --list."""
|
|
824
|
+
with self.runner.isolated_filesystem():
|
|
825
|
+
recipe_file = Path("custom.tasks")
|
|
826
|
+
recipe_file.write_text(
|
|
827
|
+
"""
|
|
828
|
+
tasks:
|
|
829
|
+
task1:
|
|
830
|
+
desc: First task
|
|
831
|
+
cmd: echo one
|
|
832
|
+
task2:
|
|
833
|
+
desc: Second task
|
|
834
|
+
cmd: echo two
|
|
835
|
+
"""
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
result = self.runner.invoke(app, ["--tasks", "custom.tasks", "--list"], env=self.env)
|
|
839
|
+
|
|
840
|
+
self.assertEqual(result.exit_code, 0)
|
|
841
|
+
self.assertIn("task1", result.stdout)
|
|
842
|
+
self.assertIn("task2", result.stdout)
|
|
843
|
+
self.assertIn("First task", result.stdout)
|
|
844
|
+
|
|
845
|
+
def test_tasks_option_with_show(self):
|
|
846
|
+
"""Test --tasks option works with --show."""
|
|
847
|
+
with self.runner.isolated_filesystem():
|
|
848
|
+
recipe_file = Path("my.tasks")
|
|
849
|
+
recipe_file.write_text(
|
|
850
|
+
"""
|
|
851
|
+
tasks:
|
|
852
|
+
build:
|
|
853
|
+
desc: Build task
|
|
854
|
+
cmd: echo building
|
|
855
|
+
"""
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
result = self.runner.invoke(app, ["--tasks", "my.tasks", "--show", "build"], env=self.env)
|
|
859
|
+
|
|
860
|
+
self.assertEqual(result.exit_code, 0)
|
|
861
|
+
self.assertIn("build:", result.stdout)
|
|
862
|
+
self.assertIn("desc: Build task", result.stdout)
|
|
863
|
+
|
|
864
|
+
def test_tasks_option_with_nonexistent_file(self):
|
|
865
|
+
"""Test --tasks option with nonexistent file shows error."""
|
|
866
|
+
with self.runner.isolated_filesystem():
|
|
867
|
+
result = self.runner.invoke(app, ["--tasks", "nonexistent.yaml", "build"], env=self.env)
|
|
868
|
+
|
|
869
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
870
|
+
self.assertIn("Recipe file not found", result.stdout)
|
|
871
|
+
self.assertIn("nonexistent.yaml", result.stdout)
|
|
872
|
+
|
|
873
|
+
|
|
652
874
|
if __name__ == "__main__":
|
|
653
875
|
unittest.main()
|
|
@@ -102,6 +102,47 @@ tasks:
|
|
|
102
102
|
finally:
|
|
103
103
|
os.chdir(original_cwd)
|
|
104
104
|
|
|
105
|
+
def test_default_working_dir_is_invocation_dir_not_tasks_file_dir(self):
|
|
106
|
+
"""Test that without explicit working_dir, tasks run from where tt is invoked, not where the tasks file is."""
|
|
107
|
+
with TemporaryDirectory() as tmpdir:
|
|
108
|
+
project_root = Path(tmpdir)
|
|
109
|
+
|
|
110
|
+
# Create subdirectory with tasks file
|
|
111
|
+
subdir = project_root / "config"
|
|
112
|
+
subdir.mkdir()
|
|
113
|
+
|
|
114
|
+
# Create tasks file in subdirectory
|
|
115
|
+
tasks_file = subdir / "build.tasks"
|
|
116
|
+
tasks_file.write_text("""
|
|
117
|
+
tasks:
|
|
118
|
+
check-location:
|
|
119
|
+
desc: Check where we execute from
|
|
120
|
+
cmd: pwd > location.txt
|
|
121
|
+
""")
|
|
122
|
+
|
|
123
|
+
original_cwd = os.getcwd()
|
|
124
|
+
try:
|
|
125
|
+
# Invoke tt from project root, pointing to tasks file in subdir
|
|
126
|
+
os.chdir(project_root)
|
|
127
|
+
|
|
128
|
+
result = self.runner.invoke(app, ["--tasks", "config/build.tasks", "check-location"], env=self.env)
|
|
129
|
+
self.assertEqual(result.exit_code, 0)
|
|
130
|
+
|
|
131
|
+
# Verify output was created in project root (where we invoked tt)
|
|
132
|
+
# NOT in config/ (where the tasks file is)
|
|
133
|
+
output_in_root = project_root / "location.txt"
|
|
134
|
+
output_in_subdir = subdir / "location.txt"
|
|
135
|
+
|
|
136
|
+
self.assertTrue(output_in_root.exists(), "Output should be in invocation directory (project root)")
|
|
137
|
+
self.assertFalse(output_in_subdir.exists(), "Output should NOT be in tasks file directory")
|
|
138
|
+
|
|
139
|
+
# Verify pwd shows project root path
|
|
140
|
+
pwd_output = output_in_root.read_text().strip()
|
|
141
|
+
self.assertEqual(Path(pwd_output).resolve(), project_root.resolve())
|
|
142
|
+
|
|
143
|
+
finally:
|
|
144
|
+
os.chdir(original_cwd)
|
|
145
|
+
|
|
105
146
|
|
|
106
147
|
if __name__ == "__main__":
|
|
107
148
|
unittest.main()
|
|
@@ -910,8 +910,8 @@ class TestFindRecipeFile(unittest.TestCase):
|
|
|
910
910
|
result = find_recipe_file(project_root)
|
|
911
911
|
self.assertIsNone(result)
|
|
912
912
|
|
|
913
|
-
def
|
|
914
|
-
"""Test
|
|
913
|
+
def test_find_recipe_file_multiple_files_raises_error(self):
|
|
914
|
+
"""Test raises error when multiple recipe files found."""
|
|
915
915
|
with TemporaryDirectory() as tmpdir:
|
|
916
916
|
project_root = Path(tmpdir).resolve()
|
|
917
917
|
tasktree_path = project_root / "tasktree.yaml"
|
|
@@ -921,8 +921,53 @@ class TestFindRecipeFile(unittest.TestCase):
|
|
|
921
921
|
tasktree_path.write_text("tasks:\n build:\n cmd: echo from tasktree")
|
|
922
922
|
tt_path.write_text("tasks:\n build:\n cmd: echo from tt")
|
|
923
923
|
|
|
924
|
+
# Should raise ValueError with helpful message
|
|
925
|
+
with self.assertRaises(ValueError) as cm:
|
|
926
|
+
find_recipe_file(project_root)
|
|
927
|
+
|
|
928
|
+
error_msg = str(cm.exception)
|
|
929
|
+
self.assertIn("Multiple recipe files found", error_msg)
|
|
930
|
+
self.assertIn("tasktree.yaml", error_msg)
|
|
931
|
+
self.assertIn("tt.yaml", error_msg)
|
|
932
|
+
self.assertIn("--tasks", error_msg)
|
|
933
|
+
|
|
934
|
+
def test_find_recipe_file_yml_extension(self):
|
|
935
|
+
"""Test finds tasktree.yml (with .yml extension)."""
|
|
936
|
+
with TemporaryDirectory() as tmpdir:
|
|
937
|
+
project_root = Path(tmpdir).resolve()
|
|
938
|
+
recipe_path = project_root / "tasktree.yml"
|
|
939
|
+
recipe_path.write_text("tasks:\n build:\n cmd: echo test")
|
|
940
|
+
|
|
941
|
+
result = find_recipe_file(project_root)
|
|
942
|
+
self.assertEqual(result, recipe_path)
|
|
943
|
+
|
|
944
|
+
def test_find_recipe_file_tasks_extension(self):
|
|
945
|
+
"""Test finds *.tasks files."""
|
|
946
|
+
with TemporaryDirectory() as tmpdir:
|
|
947
|
+
project_root = Path(tmpdir).resolve()
|
|
948
|
+
recipe_path = project_root / "build.tasks"
|
|
949
|
+
recipe_path.write_text("tasks:\n build:\n cmd: echo test")
|
|
950
|
+
|
|
924
951
|
result = find_recipe_file(project_root)
|
|
925
|
-
self.assertEqual(result,
|
|
952
|
+
self.assertEqual(result, recipe_path)
|
|
953
|
+
|
|
954
|
+
def test_find_recipe_file_multiple_tasks_files_raises_error(self):
|
|
955
|
+
"""Test raises error when multiple *.tasks files found."""
|
|
956
|
+
with TemporaryDirectory() as tmpdir:
|
|
957
|
+
project_root = Path(tmpdir).resolve()
|
|
958
|
+
build_tasks = project_root / "build.tasks"
|
|
959
|
+
test_tasks = project_root / "test.tasks"
|
|
960
|
+
|
|
961
|
+
build_tasks.write_text("tasks:\n build:\n cmd: echo build")
|
|
962
|
+
test_tasks.write_text("tasks:\n test:\n cmd: echo test")
|
|
963
|
+
|
|
964
|
+
# Should raise ValueError
|
|
965
|
+
with self.assertRaises(ValueError) as cm:
|
|
966
|
+
find_recipe_file(project_root)
|
|
967
|
+
|
|
968
|
+
error_msg = str(cm.exception)
|
|
969
|
+
self.assertIn("Multiple recipe files found", error_msg)
|
|
970
|
+
self.assertIn("--tasks", error_msg)
|
|
926
971
|
|
|
927
972
|
|
|
928
973
|
class TestEnvironmentParsing(unittest.TestCase):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree-0.0.5 → tasktree-0.0.6}/requirements/implemented/shell-environment-requirements.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|