tasktree 0.0.12__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.12 → tasktree-0.0.13}/PKG-INFO +1 -1
  3. tasktree-0.0.13/example/tasktree.yaml +33 -0
  4. {tasktree-0.0.12 → tasktree-0.0.13}/pyproject.toml +1 -1
  5. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/cli.py +45 -3
  6. tasktree-0.0.13/tests/e2e/test_non_docker.py +132 -0
  7. tasktree-0.0.13/tests/unit/test_cli.py +202 -0
  8. tasktree-0.0.12/example/tasktree.yaml +0 -16
  9. tasktree-0.0.12/tests/unit/test_cli.py +0 -109
  10. {tasktree-0.0.12 → tasktree-0.0.13}/.claude/settings.local.json +0 -0
  11. {tasktree-0.0.12 → tasktree-0.0.13}/.github/workflows/claude-code-review.yml +0 -0
  12. {tasktree-0.0.12 → tasktree-0.0.13}/.github/workflows/claude.yml +0 -0
  13. {tasktree-0.0.12 → tasktree-0.0.13}/.github/workflows/release.yml +0 -0
  14. {tasktree-0.0.12 → tasktree-0.0.13}/.github/workflows/test.yml +0 -0
  15. {tasktree-0.0.12 → tasktree-0.0.13}/.gitignore +0 -0
  16. {tasktree-0.0.12 → tasktree-0.0.13}/.python-version +0 -0
  17. {tasktree-0.0.12 → tasktree-0.0.13}/CLAUDE.md +0 -0
  18. {tasktree-0.0.12 → tasktree-0.0.13}/README.md +0 -0
  19. {tasktree-0.0.12 → tasktree-0.0.13}/example/source.txt +0 -0
  20. {tasktree-0.0.12 → tasktree-0.0.13}/requirements/implemented/01-basic-variables.md +0 -0
  21. {tasktree-0.0.12 → tasktree-0.0.13}/requirements/implemented/02-env-variable-type.md +0 -0
  22. {tasktree-0.0.12 → tasktree-0.0.13}/requirements/implemented/03-direct-env-substitution.md +0 -0
  23. {tasktree-0.0.12 → tasktree-0.0.13}/requirements/implemented/04-file-read-variables.md +0 -0
  24. {tasktree-0.0.12 → tasktree-0.0.13}/requirements/implemented/bug-report-dependency-triggering.md +0 -0
  25. {tasktree-0.0.12 → tasktree-0.0.13}/requirements/implemented/docker-task-environments.md +0 -0
  26. {tasktree-0.0.12 → tasktree-0.0.13}/requirements/implemented/shell-environment-requirements.md +0 -0
  27. {tasktree-0.0.12 → tasktree-0.0.13}/schema/README.md +0 -0
  28. {tasktree-0.0.12 → tasktree-0.0.13}/schema/tasktree-schema.json +0 -0
  29. {tasktree-0.0.12 → tasktree-0.0.13}/schema/vscode-settings-snippet.json +0 -0
  30. {tasktree-0.0.12 → tasktree-0.0.13}/src/__init__.py +0 -0
  31. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/__init__.py +0 -0
  32. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/docker.py +0 -0
  33. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/executor.py +0 -0
  34. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/graph.py +0 -0
  35. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/hasher.py +0 -0
  36. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/parser.py +0 -0
  37. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/state.py +0 -0
  38. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/substitution.py +0 -0
  39. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/tasks.py +0 -0
  40. {tasktree-0.0.12 → tasktree-0.0.13}/src/tasktree/types.py +0 -0
  41. {tasktree-0.0.12 → tasktree-0.0.13}/tasktree.yaml +0 -0
  42. {tasktree-0.0.12 → tasktree-0.0.13}/tests/e2e/__init__.py +0 -0
  43. {tasktree-0.0.12 → tasktree-0.0.13}/tests/e2e/test_docker_basic.py +0 -0
  44. {tasktree-0.0.12 → tasktree-0.0.13}/tests/e2e/test_docker_environment.py +0 -0
  45. {tasktree-0.0.12 → tasktree-0.0.13}/tests/e2e/test_docker_ownership.py +0 -0
  46. {tasktree-0.0.12 → tasktree-0.0.13}/tests/e2e/test_docker_volumes.py +0 -0
  47. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_arg_choices.py +0 -0
  48. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_arg_min_max.py +0 -0
  49. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_builtin_variables.py +0 -0
  50. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_clean_state.py +0 -0
  51. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_cli_options.py +0 -0
  52. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_dependency_execution.py +0 -0
  53. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_docker_build_args.py +0 -0
  54. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_end_to_end.py +0 -0
  55. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_exported_args.py +0 -0
  56. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_input_detection.py +0 -0
  57. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_missing_outputs.py +0 -0
  58. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_nested_imports.py +0 -0
  59. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_parameterized_dependencies.yaml +0 -0
  60. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_parameterized_deps_execution.py +0 -0
  61. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_state_persistence.py +0 -0
  62. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_variables.py +0 -0
  63. {tasktree-0.0.12 → tasktree-0.0.13}/tests/integration/test_working_directory.py +0 -0
  64. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_dependency_parsing.py +0 -0
  65. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_docker.py +0 -0
  66. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_environment_tracking.py +0 -0
  67. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_executor.py +0 -0
  68. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_graph.py +0 -0
  69. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_hasher.py +0 -0
  70. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_list_formatting.py +0 -0
  71. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_parameterized_graph.py +0 -0
  72. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_parser.py +0 -0
  73. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_state.py +0 -0
  74. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_substitution.py +0 -0
  75. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_tasks.py +0 -0
  76. {tasktree-0.0.12 → tasktree-0.0.13}/tests/unit/test_types.py +0 -0
  77. {tasktree-0.0.12 → 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.12
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
@@ -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.12"
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
 
@@ -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()
@@ -0,0 +1,202 @@
1
+ """Tests for CLI argument parsing."""
2
+
3
+ import sys
4
+ import unittest
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import typer
8
+
9
+ from tasktree.cli import (
10
+ _parse_task_args,
11
+ _supports_unicode,
12
+ get_action_failure_string,
13
+ get_action_success_string,
14
+ )
15
+
16
+
17
+ class TestParseTaskArgs(unittest.TestCase):
18
+ """Tests for _parse_task_args() function."""
19
+
20
+ def test_parse_task_args_positional(self):
21
+ """Test parsing positional arguments."""
22
+ arg_specs = ["environment", "region"]
23
+ arg_values = ["production", "us-east-1"]
24
+
25
+ result = _parse_task_args(arg_specs, arg_values)
26
+
27
+ self.assertEqual(result, {"environment": "production", "region": "us-east-1"})
28
+
29
+ def test_parse_task_args_named(self):
30
+ """Test parsing name=value arguments."""
31
+ arg_specs = ["environment", "region"]
32
+ arg_values = ["environment=production", "region=us-east-1"]
33
+
34
+ result = _parse_task_args(arg_specs, arg_values)
35
+
36
+ self.assertEqual(result, {"environment": "production", "region": "us-east-1"})
37
+
38
+ def test_parse_task_args_with_defaults(self):
39
+ """Test default values applied."""
40
+ arg_specs = ["environment", {"region": {"default": "us-west-1"}}]
41
+ arg_values = ["production"] # Only provide first arg
42
+
43
+ result = _parse_task_args(arg_specs, arg_values)
44
+
45
+ self.assertEqual(result, {"environment": "production", "region": "us-west-1"})
46
+
47
+ def test_parse_task_args_type_conversion(self):
48
+ """Test values converted to correct types."""
49
+ arg_specs = [{"port": {"type": "int"}}, {"debug": {"type": "bool"}}, {"timeout": {"type": "float"}}]
50
+ arg_values = ["8080", "true", "30.5"]
51
+
52
+ result = _parse_task_args(arg_specs, arg_values)
53
+
54
+ self.assertEqual(result, {"port": 8080, "debug": True, "timeout": 30.5})
55
+ self.assertIsInstance(result["port"], int)
56
+ self.assertIsInstance(result["debug"], bool)
57
+ self.assertIsInstance(result["timeout"], float)
58
+
59
+ def test_parse_task_args_unknown_argument(self):
60
+ """Test error for unknown argument name."""
61
+ arg_specs = ["environment"]
62
+ arg_values = ["unknown_arg=value"]
63
+
64
+ with self.assertRaises(typer.Exit):
65
+ _parse_task_args(arg_specs, arg_values)
66
+
67
+ def test_parse_task_args_too_many(self):
68
+ """Test error for too many positional args."""
69
+ arg_specs = ["environment"]
70
+ arg_values = ["production", "extra_value"]
71
+
72
+ with self.assertRaises(typer.Exit):
73
+ _parse_task_args(arg_specs, arg_values)
74
+
75
+ def test_parse_task_args_missing_required(self):
76
+ """Test error for missing required argument."""
77
+ arg_specs = ["environment", "region"]
78
+ arg_values = ["production"] # Missing 'region'
79
+
80
+ with self.assertRaises(typer.Exit):
81
+ _parse_task_args(arg_specs, arg_values)
82
+
83
+ def test_parse_task_args_invalid_type(self):
84
+ """Test error for invalid type conversion."""
85
+ arg_specs = [{"port": {"type": "int"}}]
86
+ arg_values = ["not_a_number"]
87
+
88
+ with self.assertRaises(typer.Exit):
89
+ _parse_task_args(arg_specs, arg_values)
90
+
91
+ def test_parse_task_args_empty(self):
92
+ """Test returns empty dict when no args."""
93
+ arg_specs = []
94
+ arg_values = []
95
+
96
+ result = _parse_task_args(arg_specs, arg_values)
97
+
98
+ self.assertEqual(result, {})
99
+
100
+ def test_parse_task_args_mixed(self):
101
+ """Test mixing positional and named arguments."""
102
+ arg_specs = ["environment", "region", {"verbose": {"type": "bool"}}]
103
+ arg_values = ["production", "region=us-east-1", "verbose=true"]
104
+
105
+ result = _parse_task_args(arg_specs, arg_values)
106
+
107
+ self.assertEqual(result, {
108
+ "environment": "production",
109
+ "region": "us-east-1",
110
+ "verbose": True
111
+ })
112
+
113
+
114
+ class TestUnicodeSupport(unittest.TestCase):
115
+ """Tests for Unicode symbol detection functions."""
116
+
117
+ @patch('tasktree.cli.os.environ', {})
118
+ @patch('tasktree.cli.os.name', 'posix')
119
+ @patch('tasktree.cli.sys.stdout')
120
+ def test_supports_unicode_with_utf8_encoding(self, mock_stdout):
121
+ """Test that UTF-8 encoding returns True."""
122
+ mock_stdout.encoding = 'utf-8'
123
+ self.assertTrue(_supports_unicode())
124
+
125
+ @patch('tasktree.cli.os.environ', {})
126
+ @patch('tasktree.cli.os.name', 'posix')
127
+ @patch('tasktree.cli.sys.stdout')
128
+ def test_supports_unicode_with_utf8_uppercase(self, mock_stdout):
129
+ """Test that UTF-8 (uppercase) encoding returns True."""
130
+ mock_stdout.encoding = 'UTF-8'
131
+ self.assertTrue(_supports_unicode())
132
+
133
+ @patch('tasktree.cli.os.environ', {})
134
+ @patch('tasktree.cli.os.name', 'nt')
135
+ @patch('tasktree.cli.sys.stdout')
136
+ def test_supports_unicode_on_classic_windows_console(self, mock_stdout):
137
+ """Test that classic Windows console (conhost) returns False."""
138
+ mock_stdout.encoding = 'utf-8'
139
+ # No WT_SESSION in environ means classic console
140
+ self.assertFalse(_supports_unicode())
141
+
142
+ @patch('tasktree.cli.os.environ', {'WT_SESSION': 'some-value'})
143
+ @patch('tasktree.cli.os.name', 'nt')
144
+ @patch('tasktree.cli.sys.stdout')
145
+ def test_supports_unicode_on_windows_terminal(self, mock_stdout):
146
+ """Test that Windows Terminal with UTF-8 returns True."""
147
+ mock_stdout.encoding = 'utf-8'
148
+ # WT_SESSION present means Windows Terminal
149
+ self.assertTrue(_supports_unicode())
150
+
151
+ @patch('tasktree.cli.os.environ', {})
152
+ @patch('tasktree.cli.os.name', 'posix')
153
+ @patch('tasktree.cli.sys.stdout')
154
+ def test_supports_unicode_with_encoding_that_fails_encode(self, mock_stdout):
155
+ """Test that encoding that can't encode symbols returns False."""
156
+ # ASCII encoding will fail to encode ✓✗
157
+ mock_stdout.encoding = 'ascii'
158
+ self.assertFalse(_supports_unicode())
159
+
160
+ @patch('tasktree.cli.os.environ', {})
161
+ @patch('tasktree.cli.os.name', 'posix')
162
+ @patch('tasktree.cli.sys.stdout')
163
+ def test_supports_unicode_with_none_encoding(self, mock_stdout):
164
+ """Test that None encoding returns False."""
165
+ mock_stdout.encoding = None
166
+ self.assertFalse(_supports_unicode())
167
+
168
+ @patch('tasktree.cli.os.environ', {})
169
+ @patch('tasktree.cli.os.name', 'posix')
170
+ @patch('tasktree.cli.sys.stdout')
171
+ def test_supports_unicode_with_latin1_encoding(self, mock_stdout):
172
+ """Test that Latin-1 encoding returns False (can't encode symbols)."""
173
+ mock_stdout.encoding = 'latin-1'
174
+ self.assertFalse(_supports_unicode())
175
+
176
+ @patch('tasktree.cli._supports_unicode')
177
+ def test_get_action_success_string_with_unicode(self, mock_supports):
178
+ """Test success string returns Unicode symbol when supported."""
179
+ mock_supports.return_value = True
180
+ self.assertEqual(get_action_success_string(), "✓")
181
+
182
+ @patch('tasktree.cli._supports_unicode')
183
+ def test_get_action_success_string_without_unicode(self, mock_supports):
184
+ """Test success string returns ASCII when Unicode not supported."""
185
+ mock_supports.return_value = False
186
+ self.assertEqual(get_action_success_string(), "[ OK ]")
187
+
188
+ @patch('tasktree.cli._supports_unicode')
189
+ def test_get_action_failure_string_with_unicode(self, mock_supports):
190
+ """Test failure string returns Unicode symbol when supported."""
191
+ mock_supports.return_value = True
192
+ self.assertEqual(get_action_failure_string(), "✗")
193
+
194
+ @patch('tasktree.cli._supports_unicode')
195
+ def test_get_action_failure_string_without_unicode(self, mock_supports):
196
+ """Test failure string returns ASCII when Unicode not supported."""
197
+ mock_supports.return_value = False
198
+ self.assertEqual(get_action_failure_string(), "[ FAIL ]")
199
+
200
+
201
+ if __name__ == "__main__":
202
+ unittest.main()
@@ -1,16 +0,0 @@
1
- tasks:
2
- process:
3
- desc: Process source file
4
- inputs: [source.txt]
5
- outputs: [output.txt]
6
- cmd: cat source.txt > output.txt
7
-
8
- package:
9
- desc: Create archive
10
- deps: [process]
11
- outputs: [archive.tar.gz]
12
- cmd: tar czf archive.tar.gz output.txt
13
-
14
- clean:
15
- desc: Clean generated files
16
- cmd: rm -f output.txt archive.tar.gz
@@ -1,109 +0,0 @@
1
- """Tests for CLI argument parsing."""
2
-
3
- import unittest
4
- from unittest.mock import patch
5
-
6
- import typer
7
-
8
- from tasktree.cli import _parse_task_args
9
-
10
-
11
- class TestParseTaskArgs(unittest.TestCase):
12
- """Tests for _parse_task_args() function."""
13
-
14
- def test_parse_task_args_positional(self):
15
- """Test parsing positional arguments."""
16
- arg_specs = ["environment", "region"]
17
- arg_values = ["production", "us-east-1"]
18
-
19
- result = _parse_task_args(arg_specs, arg_values)
20
-
21
- self.assertEqual(result, {"environment": "production", "region": "us-east-1"})
22
-
23
- def test_parse_task_args_named(self):
24
- """Test parsing name=value arguments."""
25
- arg_specs = ["environment", "region"]
26
- arg_values = ["environment=production", "region=us-east-1"]
27
-
28
- result = _parse_task_args(arg_specs, arg_values)
29
-
30
- self.assertEqual(result, {"environment": "production", "region": "us-east-1"})
31
-
32
- def test_parse_task_args_with_defaults(self):
33
- """Test default values applied."""
34
- arg_specs = ["environment", {"region": {"default": "us-west-1"}}]
35
- arg_values = ["production"] # Only provide first arg
36
-
37
- result = _parse_task_args(arg_specs, arg_values)
38
-
39
- self.assertEqual(result, {"environment": "production", "region": "us-west-1"})
40
-
41
- def test_parse_task_args_type_conversion(self):
42
- """Test values converted to correct types."""
43
- arg_specs = [{"port": {"type": "int"}}, {"debug": {"type": "bool"}}, {"timeout": {"type": "float"}}]
44
- arg_values = ["8080", "true", "30.5"]
45
-
46
- result = _parse_task_args(arg_specs, arg_values)
47
-
48
- self.assertEqual(result, {"port": 8080, "debug": True, "timeout": 30.5})
49
- self.assertIsInstance(result["port"], int)
50
- self.assertIsInstance(result["debug"], bool)
51
- self.assertIsInstance(result["timeout"], float)
52
-
53
- def test_parse_task_args_unknown_argument(self):
54
- """Test error for unknown argument name."""
55
- arg_specs = ["environment"]
56
- arg_values = ["unknown_arg=value"]
57
-
58
- with self.assertRaises(typer.Exit):
59
- _parse_task_args(arg_specs, arg_values)
60
-
61
- def test_parse_task_args_too_many(self):
62
- """Test error for too many positional args."""
63
- arg_specs = ["environment"]
64
- arg_values = ["production", "extra_value"]
65
-
66
- with self.assertRaises(typer.Exit):
67
- _parse_task_args(arg_specs, arg_values)
68
-
69
- def test_parse_task_args_missing_required(self):
70
- """Test error for missing required argument."""
71
- arg_specs = ["environment", "region"]
72
- arg_values = ["production"] # Missing 'region'
73
-
74
- with self.assertRaises(typer.Exit):
75
- _parse_task_args(arg_specs, arg_values)
76
-
77
- def test_parse_task_args_invalid_type(self):
78
- """Test error for invalid type conversion."""
79
- arg_specs = [{"port": {"type": "int"}}]
80
- arg_values = ["not_a_number"]
81
-
82
- with self.assertRaises(typer.Exit):
83
- _parse_task_args(arg_specs, arg_values)
84
-
85
- def test_parse_task_args_empty(self):
86
- """Test returns empty dict when no args."""
87
- arg_specs = []
88
- arg_values = []
89
-
90
- result = _parse_task_args(arg_specs, arg_values)
91
-
92
- self.assertEqual(result, {})
93
-
94
- def test_parse_task_args_mixed(self):
95
- """Test mixing positional and named arguments."""
96
- arg_specs = ["environment", "region", {"verbose": {"type": "bool"}}]
97
- arg_values = ["production", "region=us-east-1", "verbose=true"]
98
-
99
- result = _parse_task_args(arg_specs, arg_values)
100
-
101
- self.assertEqual(result, {
102
- "environment": "production",
103
- "region": "us-east-1",
104
- "verbose": True
105
- })
106
-
107
-
108
- if __name__ == "__main__":
109
- unittest.main()
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