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.
Files changed (77) hide show
  1. tasktree-0.0.13/.github/workflows/validate-pipx-install.yml +122 -0
  2. {tasktree-0.0.11 → tasktree-0.0.13}/PKG-INFO +7 -4
  3. {tasktree-0.0.11 → tasktree-0.0.13}/README.md +6 -3
  4. tasktree-0.0.13/example/tasktree.yaml +33 -0
  5. {tasktree-0.0.11 → tasktree-0.0.13}/pyproject.toml +1 -1
  6. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/cli.py +45 -3
  7. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/parser.py +41 -19
  8. tasktree-0.0.13/tests/e2e/test_non_docker.py +132 -0
  9. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_variables.py +232 -0
  10. tasktree-0.0.13/tests/unit/test_cli.py +202 -0
  11. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_parser.py +3 -3
  12. tasktree-0.0.11/example/tasktree.yaml +0 -16
  13. tasktree-0.0.11/tests/unit/test_cli.py +0 -109
  14. {tasktree-0.0.11 → tasktree-0.0.13}/.claude/settings.local.json +0 -0
  15. {tasktree-0.0.11 → tasktree-0.0.13}/.github/workflows/claude-code-review.yml +0 -0
  16. {tasktree-0.0.11 → tasktree-0.0.13}/.github/workflows/claude.yml +0 -0
  17. {tasktree-0.0.11 → tasktree-0.0.13}/.github/workflows/release.yml +0 -0
  18. {tasktree-0.0.11 → tasktree-0.0.13}/.github/workflows/test.yml +0 -0
  19. {tasktree-0.0.11 → tasktree-0.0.13}/.gitignore +0 -0
  20. {tasktree-0.0.11 → tasktree-0.0.13}/.python-version +0 -0
  21. {tasktree-0.0.11 → tasktree-0.0.13}/CLAUDE.md +0 -0
  22. {tasktree-0.0.11 → tasktree-0.0.13}/example/source.txt +0 -0
  23. {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/01-basic-variables.md +0 -0
  24. {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/02-env-variable-type.md +0 -0
  25. {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/03-direct-env-substitution.md +0 -0
  26. {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/04-file-read-variables.md +0 -0
  27. {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/bug-report-dependency-triggering.md +0 -0
  28. {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/docker-task-environments.md +0 -0
  29. {tasktree-0.0.11 → tasktree-0.0.13}/requirements/implemented/shell-environment-requirements.md +0 -0
  30. {tasktree-0.0.11 → tasktree-0.0.13}/schema/README.md +0 -0
  31. {tasktree-0.0.11 → tasktree-0.0.13}/schema/tasktree-schema.json +0 -0
  32. {tasktree-0.0.11 → tasktree-0.0.13}/schema/vscode-settings-snippet.json +0 -0
  33. {tasktree-0.0.11 → tasktree-0.0.13}/src/__init__.py +0 -0
  34. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/__init__.py +0 -0
  35. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/docker.py +0 -0
  36. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/executor.py +0 -0
  37. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/graph.py +0 -0
  38. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/hasher.py +0 -0
  39. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/state.py +0 -0
  40. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/substitution.py +0 -0
  41. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/tasks.py +0 -0
  42. {tasktree-0.0.11 → tasktree-0.0.13}/src/tasktree/types.py +0 -0
  43. {tasktree-0.0.11 → tasktree-0.0.13}/tasktree.yaml +0 -0
  44. {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/__init__.py +0 -0
  45. {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/test_docker_basic.py +0 -0
  46. {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/test_docker_environment.py +0 -0
  47. {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/test_docker_ownership.py +0 -0
  48. {tasktree-0.0.11 → tasktree-0.0.13}/tests/e2e/test_docker_volumes.py +0 -0
  49. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_arg_choices.py +0 -0
  50. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_arg_min_max.py +0 -0
  51. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_builtin_variables.py +0 -0
  52. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_clean_state.py +0 -0
  53. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_cli_options.py +0 -0
  54. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_dependency_execution.py +0 -0
  55. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_docker_build_args.py +0 -0
  56. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_end_to_end.py +0 -0
  57. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_exported_args.py +0 -0
  58. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_input_detection.py +0 -0
  59. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_missing_outputs.py +0 -0
  60. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_nested_imports.py +0 -0
  61. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_parameterized_dependencies.yaml +0 -0
  62. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_parameterized_deps_execution.py +0 -0
  63. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_state_persistence.py +0 -0
  64. {tasktree-0.0.11 → tasktree-0.0.13}/tests/integration/test_working_directory.py +0 -0
  65. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_dependency_parsing.py +0 -0
  66. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_docker.py +0 -0
  67. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_environment_tracking.py +0 -0
  68. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_executor.py +0 -0
  69. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_graph.py +0 -0
  70. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_hasher.py +0 -0
  71. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_list_formatting.py +0 -0
  72. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_parameterized_graph.py +0 -0
  73. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_state.py +0 -0
  74. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_substitution.py +0 -0
  75. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_tasks.py +0 -0
  76. {tasktree-0.0.11 → tasktree-0.0.13}/tests/unit/test_types.py +0 -0
  77. {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.11
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
- # Direct env reference (resolved at parse time)
721
+ # Required env var (error if not set)
722
722
  api_key: { env: API_KEY }
723
- db_host: { env: DATABASE_HOST }
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
- # Direct env reference (resolved at parse time)
706
+ # Required env var (error if not set)
707
707
  api_key: { env: API_KEY }
708
- db_host: { env: DATABASE_HOST }
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "tasktree"
3
- version = "0.0.11"
3
+ version = "0.0.13"
4
4
  description = "A task automation tool with incremental execution"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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] Removed {state_path}[/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] Task '{task_name}' completed successfully[/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] Task '{task_name}' failed: {e}[/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
- Environment variable name
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
- if len(value) != 1:
305
- extra_keys = [k for k in value.keys() if k != "env"]
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 extra keys: {', '.join(extra_keys)}"
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 }}\n"
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
- return env_var_name
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()