tasktree 0.0.11__tar.gz → 0.0.13__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.13/.github/workflows/validate-pipx-install.yml +122 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/PKG-INFO +7 -4
- {tasktree-0.0.11 → tasktree-0.0.13}/README.md +6 -3
- tasktree-0.0.13/example/tasktree.yaml +33 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/pyproject.toml +1 -1
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/cli.py +45 -3
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/parser.py +41 -19
- tasktree-0.0.13/tests/e2e/test_non_docker.py +132 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_variables.py +232 -0
- tasktree-0.0.13/tests/unit/test_cli.py +202 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_parser.py +3 -3
- tasktree-0.0.11/example/tasktree.yaml +0 -16
- tasktree-0.0.11/tests/unit/test_cli.py +0 -109
- {tasktree-0.0.11 → tasktree-0.0.13}/.claude/settings.local.json +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/.github/workflows/claude-code-review.yml +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/.github/workflows/claude.yml +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/.github/workflows/release.yml +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/.github/workflows/test.yml +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/.gitignore +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/.python-version +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/CLAUDE.md +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/example/source.txt +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/01-basic-variables.md +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/02-env-variable-type.md +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/03-direct-env-substitution.md +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/04-file-read-variables.md +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/bug-report-dependency-triggering.md +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/docker-task-environments.md +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/shell-environment-requirements.md +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/schema/README.md +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/schema/tasktree-schema.json +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/schema/vscode-settings-snippet.json +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/__init__.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/__init__.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/docker.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/executor.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/graph.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/hasher.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/state.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/substitution.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/tasks.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/types.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tasktree.yaml +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/__init__.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/test_docker_basic.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/test_docker_environment.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/test_docker_ownership.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/test_docker_volumes.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_arg_choices.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_arg_min_max.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_builtin_variables.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_clean_state.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_cli_options.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_dependency_execution.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_docker_build_args.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_end_to_end.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_exported_args.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_input_detection.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_missing_outputs.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_nested_imports.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_parameterized_dependencies.yaml +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_parameterized_deps_execution.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_state_persistence.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_working_directory.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_dependency_parsing.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_docker.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_environment_tracking.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_executor.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_graph.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_hasher.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_list_formatting.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_parameterized_graph.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_state.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_substitution.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_tasks.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_types.py +0 -0
- {tasktree-0.0.11 → tasktree-0.0.13}/uv.lock +0 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
name: Validate pipx Installation
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_run:
|
|
5
|
+
workflows: ["Release to PyPI"]
|
|
6
|
+
types:
|
|
7
|
+
- completed
|
|
8
|
+
workflow_dispatch: # Allow manual triggering
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
validate-installation:
|
|
12
|
+
# Only run if the release workflow succeeded
|
|
13
|
+
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
include:
|
|
19
|
+
- os: ubuntu-latest
|
|
20
|
+
platform: Ubuntu
|
|
21
|
+
- os: macos-latest
|
|
22
|
+
platform: macOS
|
|
23
|
+
- os: windows-latest
|
|
24
|
+
platform: Windows
|
|
25
|
+
|
|
26
|
+
steps:
|
|
27
|
+
- name: Checkout repository
|
|
28
|
+
uses: actions/checkout@v4
|
|
29
|
+
|
|
30
|
+
- name: Wait for PyPI propagation
|
|
31
|
+
if: github.event_name == 'workflow_run'
|
|
32
|
+
run: |
|
|
33
|
+
echo "Waiting 90 seconds for PyPI to propagate the new package..."
|
|
34
|
+
sleep 90
|
|
35
|
+
shell: bash
|
|
36
|
+
|
|
37
|
+
- name: Set up Python
|
|
38
|
+
uses: actions/setup-python@v5
|
|
39
|
+
with:
|
|
40
|
+
python-version: '3.12'
|
|
41
|
+
|
|
42
|
+
- name: Install pipx
|
|
43
|
+
run: |
|
|
44
|
+
python -m pip install --user pipx
|
|
45
|
+
python -m pipx ensurepath
|
|
46
|
+
shell: bash
|
|
47
|
+
|
|
48
|
+
- name: Install tasktree via pipx
|
|
49
|
+
run: |
|
|
50
|
+
python -m pipx install tasktree
|
|
51
|
+
shell: bash
|
|
52
|
+
|
|
53
|
+
- name: Test tt --list command
|
|
54
|
+
working-directory: example
|
|
55
|
+
run: |
|
|
56
|
+
tt --list
|
|
57
|
+
shell: bash
|
|
58
|
+
|
|
59
|
+
- name: Run test task
|
|
60
|
+
working-directory: example
|
|
61
|
+
run: |
|
|
62
|
+
tt package
|
|
63
|
+
shell: bash
|
|
64
|
+
|
|
65
|
+
- name: Verify installation
|
|
66
|
+
run: |
|
|
67
|
+
echo "✓ tasktree successfully installed via pipx on ${{ matrix.platform }}"
|
|
68
|
+
|
|
69
|
+
validate-centos:
|
|
70
|
+
# Only run if the release workflow succeeded
|
|
71
|
+
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
container:
|
|
74
|
+
image: quay.io/centos/centos:stream9
|
|
75
|
+
|
|
76
|
+
steps:
|
|
77
|
+
- name: Checkout repository
|
|
78
|
+
uses: actions/checkout@v4
|
|
79
|
+
|
|
80
|
+
- name: Wait for PyPI propagation
|
|
81
|
+
if: github.event_name == 'workflow_run'
|
|
82
|
+
run: |
|
|
83
|
+
echo "Waiting 90 seconds for PyPI to propagate the new package..."
|
|
84
|
+
sleep 90
|
|
85
|
+
|
|
86
|
+
- name: Install dependencies
|
|
87
|
+
run: |
|
|
88
|
+
dnf install -y python python-pip
|
|
89
|
+
shell: bash
|
|
90
|
+
|
|
91
|
+
- name: Install Python 3.12
|
|
92
|
+
run: dnf install -y python3.12
|
|
93
|
+
shell: bash
|
|
94
|
+
|
|
95
|
+
- name: Install pipx with Python 3.12
|
|
96
|
+
run: |
|
|
97
|
+
pip install pipx
|
|
98
|
+
pipx ensurepath
|
|
99
|
+
shell: bash
|
|
100
|
+
|
|
101
|
+
- name: Install tasktree via pipx with Python 3.12
|
|
102
|
+
run: |
|
|
103
|
+
pipx install --python python3.12 tasktree
|
|
104
|
+
shell: bash
|
|
105
|
+
|
|
106
|
+
- name: Test tt --list command
|
|
107
|
+
working-directory: example
|
|
108
|
+
run: |
|
|
109
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
110
|
+
tt --list
|
|
111
|
+
shell: bash
|
|
112
|
+
|
|
113
|
+
- name: Run test task
|
|
114
|
+
working-directory: example
|
|
115
|
+
run: |
|
|
116
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
117
|
+
tt package
|
|
118
|
+
shell: bash
|
|
119
|
+
|
|
120
|
+
- name: Verify installation
|
|
121
|
+
run: |
|
|
122
|
+
echo "✓ tasktree successfully installed via pipx on CentOS Stream 9"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasktree
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.13
|
|
4
4
|
Summary: A task automation tool with incremental execution
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: click>=8.1.0
|
|
@@ -718,10 +718,13 @@ For more complex scenarios, define environment variables in the `variables` sect
|
|
|
718
718
|
|
|
719
719
|
```yaml
|
|
720
720
|
variables:
|
|
721
|
-
#
|
|
721
|
+
# Required env var (error if not set)
|
|
722
722
|
api_key: { env: API_KEY }
|
|
723
|
-
|
|
724
|
-
|
|
723
|
+
|
|
724
|
+
# Optional env var with default
|
|
725
|
+
port: { env: PORT, default: "8080" }
|
|
726
|
+
log_level: { env: LOG_LEVEL, default: "info" }
|
|
727
|
+
|
|
725
728
|
# Or using string substitution
|
|
726
729
|
deploy_user: "{{ env.DEPLOY_USER }}"
|
|
727
730
|
|
|
@@ -703,10 +703,13 @@ For more complex scenarios, define environment variables in the `variables` sect
|
|
|
703
703
|
|
|
704
704
|
```yaml
|
|
705
705
|
variables:
|
|
706
|
-
#
|
|
706
|
+
# Required env var (error if not set)
|
|
707
707
|
api_key: { env: API_KEY }
|
|
708
|
-
|
|
709
|
-
|
|
708
|
+
|
|
709
|
+
# Optional env var with default
|
|
710
|
+
port: { env: PORT, default: "8080" }
|
|
711
|
+
log_level: { env: LOG_LEVEL, default: "info" }
|
|
712
|
+
|
|
710
713
|
# Or using string substitution
|
|
711
714
|
deploy_user: "{{ env.DEPLOY_USER }}"
|
|
712
715
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
tasks:
|
|
2
|
+
build:
|
|
3
|
+
desc: Building outputs (imagine this is a call to Cargo, or gcc, or something)
|
|
4
|
+
args:
|
|
5
|
+
- build_type:
|
|
6
|
+
choices: [ "debug", "release" ]
|
|
7
|
+
- target:
|
|
8
|
+
type: str
|
|
9
|
+
choices:
|
|
10
|
+
- "x86"
|
|
11
|
+
- "x86_64"
|
|
12
|
+
- "arm"
|
|
13
|
+
- "ppc"
|
|
14
|
+
default: "x86_64"
|
|
15
|
+
outputs: [build/**]
|
|
16
|
+
cmd: |
|
|
17
|
+
echo building for {{ arg.target }}...
|
|
18
|
+
cd build
|
|
19
|
+
echo "code and stuff" > output-{{ arg.build_type }}-{{ arg.target }}.txt
|
|
20
|
+
|
|
21
|
+
package:
|
|
22
|
+
desc: Create archive
|
|
23
|
+
deps:
|
|
24
|
+
- build: [ "debug", "x86" ]
|
|
25
|
+
- build: [ "release" ]
|
|
26
|
+
- build: { build_type: "release", target: "ppc" }
|
|
27
|
+
outputs: [archive.tar.gz]
|
|
28
|
+
cmd: echo "making archive.tar.gz..."
|
|
29
|
+
|
|
30
|
+
clean:
|
|
31
|
+
desc: Clean generated files
|
|
32
|
+
inputs: [ archive.tar.gz ]
|
|
33
|
+
cmd: rm -f archive.tar.gz
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from typing import Any, List, Optional
|
|
5
7
|
|
|
@@ -26,6 +28,46 @@ app = typer.Typer(
|
|
|
26
28
|
console = Console()
|
|
27
29
|
|
|
28
30
|
|
|
31
|
+
def _supports_unicode() -> bool:
|
|
32
|
+
"""Check if the terminal supports Unicode characters.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
True if terminal supports UTF-8, False otherwise
|
|
36
|
+
"""
|
|
37
|
+
# Hard stop: classic Windows console (conhost)
|
|
38
|
+
if os.name == "nt" and "WT_SESSION" not in os.environ:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
# Encoding check
|
|
42
|
+
encoding = sys.stdout.encoding
|
|
43
|
+
if not encoding:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
"✓✗".encode(encoding)
|
|
48
|
+
return True
|
|
49
|
+
except UnicodeEncodeError:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_action_success_string() -> str:
|
|
54
|
+
"""Get the appropriate success symbol based on terminal capabilities.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Unicode tick symbol (✓) if terminal supports UTF-8, otherwise "[ OK ]"
|
|
58
|
+
"""
|
|
59
|
+
return "✓" if _supports_unicode() else "[ OK ]"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_action_failure_string() -> str:
|
|
63
|
+
"""Get the appropriate failure symbol based on terminal capabilities.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Unicode cross symbol (✗) if terminal supports UTF-8, otherwise "[ FAIL ]"
|
|
67
|
+
"""
|
|
68
|
+
return "✗" if _supports_unicode() else "[ FAIL ]"
|
|
69
|
+
|
|
70
|
+
|
|
29
71
|
def _format_task_arguments(arg_specs: list[str | dict]) -> str:
|
|
30
72
|
"""Format task arguments for display in list output.
|
|
31
73
|
|
|
@@ -324,7 +366,7 @@ def _clean_state(tasks_file: Optional[str] = None) -> None:
|
|
|
324
366
|
|
|
325
367
|
if state_path.exists():
|
|
326
368
|
state_path.unlink()
|
|
327
|
-
console.print(f"[green]
|
|
369
|
+
console.print(f"[green]{get_action_success_string()} Removed {state_path}[/green]")
|
|
328
370
|
console.print("All tasks will run fresh on next execution")
|
|
329
371
|
else:
|
|
330
372
|
console.print(f"[yellow]No state file found at {state_path}[/yellow]")
|
|
@@ -410,9 +452,9 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
|
|
|
410
452
|
state.save()
|
|
411
453
|
try:
|
|
412
454
|
executor.execute_task(task_name, args_dict, force=force, only=only)
|
|
413
|
-
console.print(f"[green]
|
|
455
|
+
console.print(f"[green]{get_action_success_string()} Task '{task_name}' completed successfully[/green]")
|
|
414
456
|
except Exception as e:
|
|
415
|
-
console.print(f"[red]
|
|
457
|
+
console.print(f"[red]{get_action_failure_string()} Task '{task_name}' failed: {e}[/red]")
|
|
416
458
|
raise typer.Exit(1)
|
|
417
459
|
|
|
418
460
|
|
|
@@ -287,26 +287,35 @@ def _is_env_variable_reference(value: Any) -> bool:
|
|
|
287
287
|
return isinstance(value, dict) and "env" in value
|
|
288
288
|
|
|
289
289
|
|
|
290
|
-
def _validate_env_variable_reference(var_name: str, value: dict) -> str:
|
|
291
|
-
"""Validate and extract environment variable name from reference.
|
|
290
|
+
def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, str | None]:
|
|
291
|
+
"""Validate and extract environment variable name and optional default from reference.
|
|
292
292
|
|
|
293
293
|
Args:
|
|
294
294
|
var_name: Name of the variable being defined
|
|
295
|
-
value: Dict that should be { env: ENV_VAR_NAME }
|
|
295
|
+
value: Dict that should be { env: ENV_VAR_NAME } or { env: ENV_VAR_NAME, default: "value" }
|
|
296
296
|
|
|
297
297
|
Returns:
|
|
298
|
-
|
|
298
|
+
Tuple of (environment variable name, default value or None)
|
|
299
299
|
|
|
300
300
|
Raises:
|
|
301
301
|
ValueError: If reference is invalid
|
|
302
302
|
"""
|
|
303
|
-
# Validate dict structure
|
|
304
|
-
|
|
305
|
-
|
|
303
|
+
# Validate dict structure - allow 'env' and optionally 'default'
|
|
304
|
+
valid_keys = {"env", "default"}
|
|
305
|
+
invalid_keys = set(value.keys()) - valid_keys
|
|
306
|
+
if invalid_keys:
|
|
306
307
|
raise ValueError(
|
|
307
308
|
f"Invalid environment variable reference in variable '{var_name}'.\n"
|
|
308
|
-
f"Expected: {{ env: VARIABLE_NAME }}\n"
|
|
309
|
-
f"Found
|
|
309
|
+
f"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}\n"
|
|
310
|
+
f"Found invalid keys: {', '.join(invalid_keys)}"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Validate 'env' key is present
|
|
314
|
+
if "env" not in value:
|
|
315
|
+
raise ValueError(
|
|
316
|
+
f"Invalid environment variable reference in variable '{var_name}'.\n"
|
|
317
|
+
f"Missing required 'env' key.\n"
|
|
318
|
+
f"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}"
|
|
310
319
|
)
|
|
311
320
|
|
|
312
321
|
env_var_name = value["env"]
|
|
@@ -315,7 +324,7 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> str:
|
|
|
315
324
|
if not env_var_name or not isinstance(env_var_name, str):
|
|
316
325
|
raise ValueError(
|
|
317
326
|
f"Invalid environment variable reference in variable '{var_name}'.\n"
|
|
318
|
-
f"Expected: {{ env: VARIABLE_NAME }}\
|
|
327
|
+
f"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}"
|
|
319
328
|
f"Found: {{ env: {env_var_name!r} }}"
|
|
320
329
|
)
|
|
321
330
|
|
|
@@ -327,23 +336,36 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> str:
|
|
|
327
336
|
f"and contain only alphanumerics and underscores."
|
|
328
337
|
)
|
|
329
338
|
|
|
330
|
-
|
|
339
|
+
# Extract and validate default if present
|
|
340
|
+
default = value.get("default")
|
|
341
|
+
if default is not None:
|
|
342
|
+
# Default must be a string (env vars are always strings)
|
|
343
|
+
if not isinstance(default, str):
|
|
344
|
+
raise ValueError(
|
|
345
|
+
f"Invalid default value in variable '{var_name}'.\n"
|
|
346
|
+
f"Environment variable defaults must be strings.\n"
|
|
347
|
+
f"Got: {default!r} (type: {type(default).__name__})\n"
|
|
348
|
+
f"Use a quoted string: {{ env: {env_var_name}, default: \"{default}\" }}"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return env_var_name, default
|
|
331
352
|
|
|
332
353
|
|
|
333
|
-
def _resolve_env_variable(var_name: str, env_var_name: str) -> str:
|
|
354
|
+
def _resolve_env_variable(var_name: str, env_var_name: str, default: str | None = None) -> str:
|
|
334
355
|
"""Resolve environment variable value.
|
|
335
356
|
|
|
336
357
|
Args:
|
|
337
358
|
var_name: Name of the variable being defined
|
|
338
359
|
env_var_name: Name of environment variable to read
|
|
360
|
+
default: Optional default value to use if environment variable is not set
|
|
339
361
|
|
|
340
362
|
Returns:
|
|
341
|
-
Environment variable value as string
|
|
363
|
+
Environment variable value as string, or default if not set and default provided
|
|
342
364
|
|
|
343
365
|
Raises:
|
|
344
|
-
ValueError: If environment variable is not set
|
|
366
|
+
ValueError: If environment variable is not set and no default provided
|
|
345
367
|
"""
|
|
346
|
-
value = os.environ.get(env_var_name)
|
|
368
|
+
value = os.environ.get(env_var_name, default)
|
|
347
369
|
|
|
348
370
|
if value is None:
|
|
349
371
|
raise ValueError(
|
|
@@ -730,11 +752,11 @@ def _resolve_variable_value(
|
|
|
730
752
|
|
|
731
753
|
# Check if this is an environment variable reference
|
|
732
754
|
if _is_env_variable_reference(raw_value):
|
|
733
|
-
# Validate and extract env var name
|
|
734
|
-
env_var_name = _validate_env_variable_reference(name, raw_value)
|
|
755
|
+
# Validate and extract env var name and optional default
|
|
756
|
+
env_var_name, default = _validate_env_variable_reference(name, raw_value)
|
|
735
757
|
|
|
736
|
-
# Resolve from os.environ
|
|
737
|
-
string_value = _resolve_env_variable(name, env_var_name)
|
|
758
|
+
# Resolve from os.environ (with optional default)
|
|
759
|
+
string_value = _resolve_env_variable(name, env_var_name, default)
|
|
738
760
|
|
|
739
761
|
# Still perform variable-in-variable substitution
|
|
740
762
|
from tasktree.substitution import substitute_variables
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""E2E tests for non-docker environments."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
|
|
7
|
+
from . import run_tasktree_cli
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestNonDockerEnvironment(unittest.TestCase):
|
|
11
|
+
"""Test basic task execution without Docker."""
|
|
12
|
+
|
|
13
|
+
def test_simple_parameterized_task(self):
|
|
14
|
+
"""Test that parameterized task executes with default environment value."""
|
|
15
|
+
with TemporaryDirectory() as tmpdir:
|
|
16
|
+
project_root = Path(tmpdir)
|
|
17
|
+
|
|
18
|
+
# Create recipe with parameterized task
|
|
19
|
+
(project_root / "tasktree.yaml").write_text("""
|
|
20
|
+
tasks:
|
|
21
|
+
deploy:
|
|
22
|
+
args:
|
|
23
|
+
- foo
|
|
24
|
+
- environment: { type: str, choices: ["dev", "staging", "prod"], default: "dev" }
|
|
25
|
+
outputs: [deploy.log]
|
|
26
|
+
cmd: |
|
|
27
|
+
echo "environment={{ arg.environment }}" > deploy.log
|
|
28
|
+
echo "foo was {{ arg.foo }}"
|
|
29
|
+
""")
|
|
30
|
+
|
|
31
|
+
# Execute with positional argument and default environment
|
|
32
|
+
result = run_tasktree_cli(["deploy", "42"], cwd=project_root)
|
|
33
|
+
|
|
34
|
+
# Assert success
|
|
35
|
+
self.assertEqual(
|
|
36
|
+
result.returncode,
|
|
37
|
+
0,
|
|
38
|
+
f"CLI failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Verify output file
|
|
42
|
+
output_file = project_root / "deploy.log"
|
|
43
|
+
self.assertTrue(output_file.exists(), "Output file not created")
|
|
44
|
+
self.assertEqual(
|
|
45
|
+
output_file.read_text().strip(),
|
|
46
|
+
"environment=dev"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Verify terminal output
|
|
50
|
+
self.assertIn("foo was 42", result.stdout)
|
|
51
|
+
|
|
52
|
+
def test_parameterized_task_with_custom_environment(self):
|
|
53
|
+
"""Test that parameterized task executes with explicit environment value."""
|
|
54
|
+
with TemporaryDirectory() as tmpdir:
|
|
55
|
+
project_root = Path(tmpdir)
|
|
56
|
+
|
|
57
|
+
# Create recipe with parameterized task
|
|
58
|
+
(project_root / "tasktree.yaml").write_text("""
|
|
59
|
+
tasks:
|
|
60
|
+
deploy:
|
|
61
|
+
args:
|
|
62
|
+
- foo
|
|
63
|
+
- environment: { type: str, choices: ["dev", "staging", "prod"], default: "dev" }
|
|
64
|
+
outputs: [deploy.log]
|
|
65
|
+
cmd: |
|
|
66
|
+
echo "environment={{ arg.environment }}" > deploy.log
|
|
67
|
+
echo "foo was {{ arg.foo }}"
|
|
68
|
+
""")
|
|
69
|
+
|
|
70
|
+
# Execute with both arguments
|
|
71
|
+
result = run_tasktree_cli(["deploy", "42", "environment=prod"], cwd=project_root)
|
|
72
|
+
|
|
73
|
+
# Assert success
|
|
74
|
+
self.assertEqual(
|
|
75
|
+
result.returncode,
|
|
76
|
+
0,
|
|
77
|
+
f"CLI failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Verify output file
|
|
81
|
+
output_file = project_root / "deploy.log"
|
|
82
|
+
self.assertTrue(output_file.exists(), "Output file not created")
|
|
83
|
+
self.assertEqual(
|
|
84
|
+
output_file.read_text().strip(),
|
|
85
|
+
"environment=prod"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Verify terminal output
|
|
89
|
+
self.assertIn("foo was 42", result.stdout)
|
|
90
|
+
|
|
91
|
+
def test_list_tasks_output(self):
|
|
92
|
+
"""Test that --list displays tasks with correct formatting."""
|
|
93
|
+
with TemporaryDirectory() as tmpdir:
|
|
94
|
+
project_root = Path(tmpdir)
|
|
95
|
+
|
|
96
|
+
# Create recipe with parameterized task
|
|
97
|
+
(project_root / "tasktree.yaml").write_text("""
|
|
98
|
+
tasks:
|
|
99
|
+
deploy:
|
|
100
|
+
desc: Deploy to environment
|
|
101
|
+
args:
|
|
102
|
+
- foo
|
|
103
|
+
- environment: { type: str, choices: ["dev", "staging", "prod"], default: "dev" }
|
|
104
|
+
outputs: [deploy.log]
|
|
105
|
+
cmd: |
|
|
106
|
+
echo "environment={{ arg.environment }}" > deploy.log
|
|
107
|
+
echo "foo was {{ arg.foo }}"
|
|
108
|
+
""")
|
|
109
|
+
|
|
110
|
+
# Execute --list
|
|
111
|
+
result = run_tasktree_cli(["--list"], cwd=project_root)
|
|
112
|
+
|
|
113
|
+
# Assert success
|
|
114
|
+
self.assertEqual(
|
|
115
|
+
result.returncode,
|
|
116
|
+
0,
|
|
117
|
+
f"CLI failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Verify task name is in output
|
|
121
|
+
self.assertIn("deploy", result.stdout)
|
|
122
|
+
|
|
123
|
+
# Verify description is in output
|
|
124
|
+
self.assertIn("Deploy to environment", result.stdout)
|
|
125
|
+
|
|
126
|
+
# Verify arguments are shown (the actual format includes ANSI codes, so we check for the argument names)
|
|
127
|
+
self.assertIn("foo", result.stdout)
|
|
128
|
+
self.assertIn("environment", result.stdout)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
unittest.main()
|