diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..b8bb15b --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,84 @@ +name: CD + +run-name: CD - ${{ github.event_name == 'workflow_run' && format('{0} (from CI)', github.event.workflow_run.head_commit.message) || format('{0}{1}', github.event.inputs.environment != '' && github.event.inputs.environment || 'dev', github.event.inputs.version != '' && format(' (v{0})', github.event.inputs.version) || '') }} + +on: + workflow_dispatch: + inputs: + version: + description: "Release version" + default: "None" + required: false + workflow_run: + workflows: ["CI"] + branches: [main] + types: + - completed + +env: + CD: ${{ vars.CONTINUOUS_DEPLOYMENT }} + DEPLOY_MODE: "true" + VERSION: ${{ github.event.inputs.version != '' && github.event.inputs.version || 'None' }} + +jobs: + # Continuous Deployment (CD) pipeline + cd: + if: ${{ vars.CONTINUOUS_DEPLOYMENT == 'true' && github.ref_name == 'main' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')) }} + permissions: + contents: write + id-token: write + timeout-minutes: 15 + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + platforms: [linux/amd64] + steps: + - name: Checkout Git repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv and set the Python version + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + shell: bash + run: uvx uvtask dev-install + + - name: Set version + shell: bash + run: | + if [ -z "${VERSION}" ] || [ "${VERSION}" = "None" ]; then + uv version --bump minor + VERSION=$(uv version --short) + echo "VERSION=${VERSION}" >> "${GITHUB_ENV:-/dev/null}" + else + uv version ${VERSION} + fi + git config user.name "${{ github.actor }}" + git config user.email "${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com" + git tag -a "${VERSION}" -m "Release ${VERSION}" + git push origin "${VERSION}" + if: ${{ env.PIPELINE_TAG == 'true' && env.RELEASE_MODE == 'true' }} + + - name: Build package + shell: bash + run: uv build + if: ${{ env.PIPELINE_TAG == 'true' && env.RELEASE_MODE == 'true' }} + + - name: Upload package to artifact registry + uses: actions/upload-artifact@v6 + with: + name: uvtask + path: dist/ + if: ${{ env.PIPELINE_TAG == 'true' && env.RELEASE_MODE == 'true' }} + + - name: Publish package + shell: bash + run: uv publish + if: ${{ env.PIPELINE_TAG == 'true' && env.RELEASE_MODE == 'true' }} + + - name: Clean + shell: bash + run: uvx uvtask clean diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bafa559 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +run-name: CI - ${{ github.event_name == 'pull_request' && github.event.pull_request.title || github.event_name == 'push' && github.event.head_commit.message || github.ref_name }} ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version != '' && format('(v{0})', github.event.inputs.version) || '' }} + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + version: + description: "Release version" + default: "None" + required: false + +env: + CI: "true" + +jobs: + # Continuous Integration (CI) pipeline + ci: + if: ${{ vars.CONTINUOUS_INTEGRATION == 'true' }} + permissions: + contents: read + env: + PIPELINE_TESTS: ${{ github.event_name != 'workflow_dispatch' && github.event.inputs.version == '' && startsWith(github.ref, 'refs/tags/') == false && github.ref != 'refs/heads/main' && 'true' || 'false' }} + RELEASE_MODE: "false" + VERSION: ${{ github.run_id }} + timeout-minutes: 15 + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + platforms: [linux/amd64, linux/arm64] + steps: + - name: Checkout Git repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv and set the Python version + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + shell: bash + run: uvx uvtask dev-install + + - name: security-analysis-licenses + shell: bash + run: uvx uvtask security-analysis:licenses + if: ${{ env.PIPELINE_TESTS == 'true' }} + + - name: security-analysis-vulnerabilities + shell: bash + run: uvx uvtask security-analysis:vulnerabilities + if: ${{ env.PIPELINE_TESTS == 'true' }} + + - name: static-analysis-linter + shell: bash + run: uvx uvtask static-analysis:linter + if: ${{ env.PIPELINE_TESTS == 'true' }} + + - name: static-analysis-types + shell: bash + run: uvx uvtask static-analysis:types + if: ${{ env.PIPELINE_TESTS == 'true' }} + + - name: unit-tests + shell: bash + run: uvx uvtask unit-tests + if: ${{ env.PIPELINE_TESTS == 'true' }} + + - name: integration-tests + shell: bash + run: uvx uvtask integration-tests + if: ${{ env.PIPELINE_TESTS == 'true' }} + + - name: Clean + shell: bash + run: uvx uvtask clean diff --git a/.gitignore b/.gitignore index 4d7f175..7568a36 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.egg-info/ *.pyc __pycache__/ +.coverage build/ dist/ requirements-dev.txt diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..3767b4b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 \ No newline at end of file diff --git a/README.md b/README.md index fe85992..198ab3e 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,54 @@ -# 🚀 uvtask +# uvtask -[![PyPI version](https://badge.fury.io/py/uvtask.svg)](https://badge.fury.io/py/uvtask) +[![image](https://img.shields.io/pypi/v/uvtask.svg)](https://pypi.python.org/pypi/uvtask) +[![image](https://img.shields.io/pypi/l/uvtask.svg)](https://pypi.python.org/pypi/uvtask) +[![image](https://img.shields.io/pypi/pyversions/uvtask.svg)](https://pypi.python.org/pypi/uvtask) +[![Actions status](https://github.com/aiopy/python-uvtask/actions/workflows/ci.yml/badge.svg)](https://github.com/aiopy/python-uvtask/actions) [![PyPIDownloads](https://static.pepy.tech/badge/uvtask)](https://pepy.tech/project/uvtask) -**uvtask** is a modern, fast, and flexible Python task runner and test automation tool designed to simplify development workflows. It supports running, organizing, and managing tasks or tests in Python projects with an emphasis on ease of use and speed. ⚡ +An extremely fast Python task runner. + +## Highlights + +- ⚡ **Extremely fast** - Built for speed with zero installation overhead +- 📝 **Simple configuration** - Define scripts in `pyproject.toml` +- 🔗 **Pre/post hooks** - Automatically run hooks before and after commands +- 🎨 **Beautiful output** - Colorful, `uv`-inspired CLI ## 🎯 Quick Start -Run tasks defined in your `pyproject.toml`: +Run `uvtask` directly with `uvx` (no installation required): ```shell -uvx uvtask +uvx uvtask [COMMAND] +``` + +Or install it and use it directly: + +```shell +uv add --dev uvtask +uvtask [COMMAND] ``` ## 📝 Configuration -Define your tasks in `pyproject.toml` under the `[tool.run-script]` section: +Define your scripts in `pyproject.toml` under the `[tool.run-script]` section: ```toml [tool.run-script] -hello-world = "echo 'hello world'" +install = "uv sync --dev --all-extras" +format = "ruff format ." +lint = { command = "ruff check .", description = "Check code quality" } +check = ["ty check .", "mypy ."] +pre-test = "echo 'Running tests...'" +test = "pytest" +post-test = "echo 'Tests completed!'" +deploy = [ + "echo 'Building...'", + "uv build", + "echo 'Deploying...'", + "uv deploy" +] ``` ## 🛠️ Development @@ -27,20 +56,12 @@ hello-world = "echo 'hello world'" To run the development version: ```shell -uvx --no-cache --from $PWD run --help +uvx -q --no-cache --from $PWD uvtask ``` -## 📋 Requirements - -- 🐍 Python >= 3.13 - ## 🤝 Contributing -Contributions are welcome! 🎉 - -- For major changes, please open an issue first to discuss what you would like to change -- Make sure to update tests as appropriate -- Follow the existing code style and conventions +Contributions are welcome! Please feel free to submit a Pull Request. ## 📄 License diff --git a/pyproject.toml b/pyproject.toml index a6c6418..2df4e8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,10 @@ [build-system] -build-backend = "setuptools.build_meta" -requires = ["setuptools"] +requires = ["uv_build"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "uvtask" +module-root = "" [project] authors = [ @@ -27,8 +31,8 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [] -description = "uvtask is a modern, fast, and flexible Python task runner and test automation tool designed to simplify development workflows. It supports running, organizing, and managing tasks or tests in Python projects with an emphasis on ease of use and speed." -dynamic = ["version"] +description = "An extremely fast Python task runner." +version = "0.0.0" keywords = [ "uv", "uvx", @@ -51,25 +55,13 @@ dev = [ "pytest-cov>=7.0.0", # test, coverage "pytest-xdist>=3.8.0", # test "ruff>=0.14.10", # code-formatter, static-analysis - "ty>=0.0.4", # static-analysis + "ty>=0.0.6", # static-analysis ] [project.urls] "documentation" = "https://aiopy.github.io/python-uvtask/" "repository" = "https://github.com/aiopy/python-uvtask" -[tool.setuptools.dynamic] -version = { attr = "uvtask.__version__" } - -[tool.setuptools.packages.find] -include = ["uvtask*"] - -[tool.setuptools.package-data] -"uvtask" = ["py.typed"] - -[[tool.uv.index]] -url = "https://pypi.org/simple" - [tool.bandit] exclude_dirs = ["tests"] skips = ["B404", "B602"] @@ -103,6 +95,8 @@ lint.select = [ ] lint.ignore = [ "PLC0415", + "PLR0912", + "PLR0913", "PLR2004", ] @@ -118,7 +112,7 @@ force-single-line = false known-first-party = ["uvtask"] [tool.ruff.lint.mccabe] -max-complexity = 15 +max-complexity = 20 [tool.ty.environment] python-version = "3.13" @@ -127,6 +121,7 @@ python-version = "3.13" exclude = [ "tests/fixtures/**", "var", + ".venv", ] [tool.run-script] @@ -134,28 +129,26 @@ install = "uv sync --frozen --no-dev" upgrade-install = "uv sync --frozen --no-dev --upgrade --refresh" dev-install = "uv sync --dev --all-extras" upgrade-dev-install = "uv sync --dev --all-extras --upgrade --refresh" -deploy = "uv build && uv publish" -docs = "python3 -m mkdocs build -f docs_src/config/en/mkdocs.yml && python3 -m mkdocs build -f docs_src/config/es/mkdocs.yml" -dev-docs = "python3 -m mkdocs serve -f docs_src/config/en/mkdocs.yml" -code-formatter = "uv run ruff format uvtask tests $@" +code-formatter = "uv run ruff format uvtask tests" "security-analysis:licenses" = "uv run pip-licenses" "security-analysis:vulnerabilities" = "uv run bandit -r -c pyproject.toml uvtask tests" "static-analysis:linter" = "uv run ruff check uvtask tests" "static-analysis:types" = "uv run ty check uvtask tests" -test = "uv run pytest" unit-tests = "uv run pytest tests/unit" integration-tests = "uv run pytest tests/integration" functional-tests = "uv run pytest -n1 tests/functional" coverage = "uv run pytest -n1 --cov --cov-report=html" -clean = """python3 -c \" +clean = """python3 -c " from glob import iglob from shutil import rmtree for pathname in ['./build', './*.egg-info', './dist', './var', '**/__pycache__']: for path in iglob(pathname, recursive=True): rmtree(path, ignore_errors=True) -\"""" +" +""" [project.scripts] uvtask = "uvtask.cli:main" +run = "uvtask.cli:main" run-script = "uvtask.cli:main" diff --git a/tests/__init__.py b/tests/__init__.py index 96dbe31..8b13789 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Test package for uvtask.""" + diff --git a/tests/conftest.py b/tests/conftest.py index 97e625e..2439a83 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -"""Pytest configuration and fixtures.""" - from pathlib import Path from tempfile import TemporaryDirectory from typing import Generator @@ -9,14 +7,12 @@ @pytest.fixture def temp_dir() -> Generator[Path, None, None]: - """Create a temporary directory for testing.""" with TemporaryDirectory() as tmpdir: yield Path(tmpdir) @pytest.fixture def pyproject_toml(temp_dir: Path) -> Path: - """Create a sample pyproject.toml file in a temporary directory.""" pyproject_path = temp_dir / "pyproject.toml" pyproject_content = """[tool.run-script] test = "echo test" @@ -30,7 +26,6 @@ def pyproject_toml(temp_dir: Path) -> Path: @pytest.fixture def empty_pyproject_toml(temp_dir: Path) -> Path: - """Create an empty pyproject.toml file (no run-script section).""" pyproject_path = temp_dir / "pyproject.toml" pyproject_content = """[project] name = "test" diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py index 96dbe31..8b13789 100644 --- a/tests/functional/__init__.py +++ b/tests/functional/__init__.py @@ -1 +1 @@ -"""Test package for uvtask.""" + diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 96dbe31..8b13789 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1 +1 @@ -"""Test package for uvtask.""" + diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 533b3bf..be1b0ed 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -1,239 +1,167 @@ -"""Integration tests for uvtask.""" - import os import subprocess import sys from pathlib import Path -# Get the path to the project root and CLI module -PROJECT_ROOT = Path(__file__).parent.parent -CLI_MODULE = "uvtask.cli" - - -class TestIntegration: - """Integration tests for end-to-end scenarios.""" - - def test_full_workflow_with_real_command(self, pyproject_toml: Path) -> None: - """Test full workflow executing a real command.""" - original_cwd = Path.cwd() - try: - os.chdir(pyproject_toml.parent) - # Run uvtask with a simple echo command - env = os.environ.copy() - env["PYTHONPATH"] = str(PROJECT_ROOT) - result = subprocess.run( - [sys.executable, "-m", CLI_MODULE, "test"], - check=False, - capture_output=True, - text=True, - cwd=pyproject_toml.parent, - env=env, - ) - assert result.returncode == 0 - assert "test" in result.stdout or result.stdout == "" - finally: - os.chdir(original_cwd) - - def test_help_integration(self, pyproject_toml: Path) -> None: - """Test help command in integration scenario.""" - original_cwd = Path.cwd() - try: - os.chdir(pyproject_toml.parent) - env = os.environ.copy() - env["PYTHONPATH"] = str(PROJECT_ROOT) - env["PYTHONUNBUFFERED"] = "1" - result = subprocess.run( - [sys.executable, "-u", "-m", CLI_MODULE, "--help"], - check=False, - capture_output=True, - text=True, - cwd=pyproject_toml.parent, - env=env, - ) - assert result.returncode == 0 - # Output might be in stdout or stderr depending on how exit() is handled - output = (result.stdout or "") + (result.stderr or "") - # If output is empty, the command at least succeeded (exit code 0) - if output: - assert "Usage: uvtask [COMMAND]" in output or "test" in output - finally: - os.chdir(original_cwd) - - def test_error_handling_integration(self, temp_dir: Path) -> None: - """Test error handling when pyproject.toml is missing.""" - original_cwd = Path.cwd() - try: - os.chdir(temp_dir) - env = os.environ.copy() - env["PYTHONPATH"] = str(PROJECT_ROOT) - env["PYTHONUNBUFFERED"] = "1" - # Use python -c to properly propagate exit codes - result = subprocess.run( - [ - sys.executable, - "-u", - "-c", - f"import sys; sys.path.insert(0, '{PROJECT_ROOT}'); from uvtask.cli import main; sys.argv = ['uvtask', 'test']; main()", - ], - check=False, - capture_output=True, - text=True, - cwd=temp_dir, - env=env, - ) - # When run via -m, SystemExit might not propagate, so check output instead - output = (result.stdout or "") + (result.stderr or "") - if result.returncode == 1: - # Exit code properly propagated - if output: - assert "Error: pyproject.toml not found" in output - else: - # Exit code not propagated (Python -m issue), check output - assert "Error: pyproject.toml not found" in output - finally: - os.chdir(original_cwd) - - def test_unknown_command_integration(self, pyproject_toml: Path) -> None: - """Test unknown command error in integration scenario.""" - original_cwd = Path.cwd() - try: - os.chdir(pyproject_toml.parent) - env = os.environ.copy() - env["PYTHONPATH"] = str(PROJECT_ROOT) - env["PYTHONUNBUFFERED"] = "1" - # Use python -c to properly propagate exit codes - result = subprocess.run( - [ - sys.executable, - "-u", - "-c", - f"import sys; sys.path.insert(0, '{PROJECT_ROOT}'); from uvtask.cli import main; sys.argv = ['uvtask', 'nonexistent']; main()", - ], - check=False, - capture_output=True, - text=True, - cwd=pyproject_toml.parent, - env=env, - ) - # When run via -c, SystemExit should propagate, but check output as well - output = (result.stdout or "") + (result.stderr or "") - # The command should fail with exit code 1 - # If exit code is 0, it might have shown help instead (which is also a valid behavior) - if result.returncode == 1: - # Exit code properly propagated - error occurred - assert "Error: Unknown command 'nonexistent'!" in output - elif result.returncode == 0: - # If exit code is 0, it might have shown help (fallback behavior) - # In this case, we just verify the command was processed - assert len(output) > 0 # Some output was produced - finally: - os.chdir(original_cwd) - - def test_command_with_arguments_integration(self, temp_dir: Path) -> None: - """Test command execution with arguments in integration scenario.""" - original_cwd = Path.cwd() +PROJECT_ROOT = Path(__file__).parent.parent.parent.absolute() + + +def run_uvtask(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PYTHONPATH"] = str(PROJECT_ROOT.absolute()) + env["PYTHONUNBUFFERED"] = "1" + + result = subprocess.run( + [sys.executable, "-m", "uvtask", *args], + check=False, + capture_output=True, + text=True, + cwd=cwd, + env=env, + ) + return result + + +class TestBasicExecution: + def test_execute_simple_command(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path.write_text('[tool.run-script]\ntest = "echo hello"\n') + + result = run_uvtask(["test"], temp_dir) + assert result.returncode == 0 + assert "hello" in result.stdout + + def test_execute_command_with_args(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path.write_text('[tool.run-script]\ngreet = "echo"\n') + + result = run_uvtask(["greet", "hello", "world"], temp_dir) + assert result.returncode == 0 + assert "hello" in result.stdout + assert "world" in result.stdout + + def test_execute_multiple_commands(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path.write_text('[tool.run-script]\nmulti = ["echo first", "echo second"]\n') + + result = run_uvtask(["multi"], temp_dir) + assert result.returncode == 0 + assert "first" in result.stdout + assert "second" in result.stdout + + +class TestHooks: + def test_pre_and_post_hooks(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path.write_text('[tool.run-script]\npre-test = "echo pre"\ntest = "echo main"\npost-test = "echo post"\n') + + result = run_uvtask(["test"], temp_dir) + assert result.returncode == 0 + output = result.stdout + result.stderr + assert "pre" in output + assert "main" in output + assert "post" in output + + def test_no_hooks_flag(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path.write_text('[tool.run-script]\npre-test = "echo pre"\ntest = "echo main"\npost-test = "echo post"\n') + + result = run_uvtask(["--no-hooks", "test"], temp_dir) + assert result.returncode == 0 + output = result.stdout + result.stderr + assert "main" in output + assert "pre" not in output + assert "post" not in output + + +class TestHelp: + def test_help_command(self, pyproject_toml: Path) -> None: + result = run_uvtask(["help"], pyproject_toml.parent) + assert result.returncode == 0 + assert "Usage:" in result.stdout or "Usage:" in result.stderr + + def test_help_specific_command(self, pyproject_toml: Path) -> None: + result = run_uvtask(["help", "test"], pyproject_toml.parent) + assert result.returncode == 0 + + def test_help_unknown_command(self, pyproject_toml: Path) -> None: + result = run_uvtask(["help", "nonexistent"], pyproject_toml.parent) + assert result.returncode == 1 + assert "error" in result.stderr.lower() or "unknown" in result.stderr.lower() + + +class TestMultilineCommands: + def test_multiline_command_with_description(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_content = '''[tool.run-script] +clean = { command = """python3 -c " +import os +print('Cleaning...') +print('Done!') +" +""", description = "Clean build artifacts" } +''' + pyproject_path.write_text(pyproject_content) + + result = run_uvtask(["clean"], temp_dir) + assert result.returncode == 0 + assert "Cleaning..." in result.stdout + assert "Done!" in result.stdout + + def test_multiline_command_help(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_content = '''[tool.run-script] +clean = { command = """python3 -c " +import os +print('Clean') +" +""", description = "Clean build artifacts" } +''' + pyproject_path.write_text(pyproject_content) + + result = run_uvtask(["help", "clean"], temp_dir) + assert result.returncode == 0 + assert "Clean build artifacts" in result.stdout + + +class TestCommandChaining: + def test_command_references_other_commands(self, temp_dir: Path) -> None: pyproject_path = temp_dir / "pyproject.toml" - pyproject_content = """[tool.run-script] -greet = "echo hello" -""" + pyproject_content = '''[tool.run-script] +lint = "echo lint" +test = "echo test" +check = { command = ["lint", "test"], description = "Run lint and test" } +''' pyproject_path.write_text(pyproject_content) - try: - os.chdir(temp_dir) - env = os.environ.copy() - env["PYTHONPATH"] = str(PROJECT_ROOT) - result = subprocess.run( - [sys.executable, "-m", CLI_MODULE, "greet", "world"], - check=False, - capture_output=True, - text=True, - cwd=temp_dir, - env=env, - ) - # Command should execute successfully - assert result.returncode == 0 - finally: - os.chdir(original_cwd) - - def test_multiple_commands_integration(self, pyproject_toml: Path) -> None: - """Test that multiple commands can be listed and executed.""" - original_cwd = Path.cwd() - try: - os.chdir(pyproject_toml.parent) - # First, check help shows all commands - env = os.environ.copy() - env["PYTHONPATH"] = str(PROJECT_ROOT) - env["PYTHONUNBUFFERED"] = "1" - help_result = subprocess.run( - [sys.executable, "-u", "-m", CLI_MODULE, "--help"], - check=False, - capture_output=True, - text=True, - cwd=pyproject_toml.parent, - env=env, - ) - assert help_result.returncode == 0 - help_output = (help_result.stdout or "") + (help_result.stderr or "") - # If output is empty, at least verify the command succeeded - if help_output: - # Verify all expected commands are present - assert "test" in help_output or "build" in help_output or "lint" in help_output - - # Test executing one of them - test_result = subprocess.run( - [sys.executable, "-m", CLI_MODULE, "test"], - check=False, - capture_output=True, - text=True, - cwd=pyproject_toml.parent, - env=env, - ) - assert test_result.returncode == 0 - finally: - os.chdir(original_cwd) - - def test_complex_pyproject_toml(self, temp_dir: Path) -> None: - """Test with a more complex pyproject.toml structure.""" - original_cwd = Path.cwd() + + result = run_uvtask(["check"], temp_dir) + assert result.returncode == 0 + assert "lint" in result.stdout + assert "test" in result.stdout + + def test_nested_command_references(self, temp_dir: Path) -> None: pyproject_path = temp_dir / "pyproject.toml" - pyproject_content = """[project] -name = "test-project" -version = "1.0.0" - -[tool.run-script] -simple = "echo simple" -complex = "echo 'complex command'" -with-args = "echo $@" -""" + pyproject_content = '''[tool.run-script] +lint = "echo lint" +format = "echo format" +static = { command = ["lint", "format"], description = "Static analysis" } +all = { command = ["static"], description = "Run all checks" } +''' pyproject_path.write_text(pyproject_content) - try: - os.chdir(temp_dir) - # Test help shows all commands - env = os.environ.copy() - env["PYTHONPATH"] = str(PROJECT_ROOT) - env["PYTHONUNBUFFERED"] = "1" - help_result = subprocess.run( - [sys.executable, "-u", "-m", CLI_MODULE, "--help"], - check=False, - capture_output=True, - text=True, - cwd=temp_dir, - env=env, - ) - assert help_result.returncode == 0 - help_output = (help_result.stdout or "") + (help_result.stderr or "") - # If output is empty, at least verify the command succeeded - if help_output: - assert "simple" in help_output or "complex" in help_output or "with-args" in help_output - - # Test executing a command - result = subprocess.run( - [sys.executable, "-m", CLI_MODULE, "simple"], - check=False, - capture_output=True, - text=True, - cwd=temp_dir, - env=env, - ) - assert result.returncode == 0 - finally: - os.chdir(original_cwd) + + result = run_uvtask(["all"], temp_dir) + assert result.returncode == 0 + assert "lint" in result.stdout + assert "format" in result.stdout + + +class TestErrorHandling: + def test_unknown_command(self, pyproject_toml: Path) -> None: + result = run_uvtask(["unknown"], pyproject_toml.parent) + assert result.returncode == 1 + assert "error" in result.stderr.lower() + + def test_missing_pyproject(self, temp_dir: Path) -> None: + result = run_uvtask(["test"], temp_dir) + assert result.returncode == 1 + assert "pyproject.toml" in result.stderr.lower() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 96dbe31..8b13789 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1 +1 @@ -"""Test package for uvtask.""" + diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index a210438..9c5cc59 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,200 +1,153 @@ -"""Unit tests for uvtask CLI.""" - -import os from pathlib import Path -from subprocess import CompletedProcess -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest -from uvtask.cli import main - +from uvtask.cli import CliApplication -class TestCLI: - """Test cases for CLI functionality.""" - def test_missing_pyproject_toml(self, temp_dir: Path, capsys: pytest.CaptureFixture[str]) -> None: - """Test error when pyproject.toml is missing.""" - # Change to temp directory without pyproject.toml +class TestCliApplication: + def test_run_missing_pyproject(self, temp_dir: Path) -> None: original_cwd = Path.cwd() try: import os os.chdir(temp_dir) + mock_script_loader = MagicMock() + mock_version_loader = MagicMock() + mock_parser_builder = MagicMock() + mock_argv_parser = MagicMock() + mock_validator = MagicMock() + mock_builder = MagicMock() + mock_help_handler = MagicMock() + mock_orchestrator = MagicMock() + + app = CliApplication( + mock_script_loader, + mock_version_loader, + mock_parser_builder, + mock_argv_parser, + mock_validator, + mock_builder, + mock_help_handler, + mock_orchestrator, + ) + with pytest.raises(SystemExit) as exc_info: - main() + app.run() assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "Error: pyproject.toml not found in current directory!" in captured.out finally: os.chdir(original_cwd) - def test_help_output(self, pyproject_toml: Path, capsys: pytest.CaptureFixture[str]) -> None: - """Test help output displays available commands.""" + def test_run_validates_reserved_commands(self, pyproject_toml: Path) -> None: original_cwd = Path.cwd() try: - os.chdir(pyproject_toml.parent) - with patch("uvtask.cli.argv", ["uvtask", "-h"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert "Usage: uvtask [COMMAND]" in captured.out - assert "test" in captured.out - assert "build" in captured.out - assert "lint" in captured.out - assert "-h,--help" in captured.out - finally: - os.chdir(original_cwd) - - def test_help_variants(self, pyproject_toml: Path, capsys: pytest.CaptureFixture[str]) -> None: - """Test different help flag variants.""" - original_cwd = Path.cwd() - try: - os.chdir(pyproject_toml.parent) - - for help_flag in ["-h", "-help", "--help"]: - with patch("uvtask.cli.argv", ["uvtask", help_flag]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert "Usage: uvtask [COMMAND]" in captured.out - finally: - os.chdir(original_cwd) + import os - def test_no_args_shows_help(self, pyproject_toml: Path, capsys: pytest.CaptureFixture[str]) -> None: - """Test that no arguments defaults to help.""" - original_cwd = Path.cwd() - try: os.chdir(pyproject_toml.parent) - with patch("uvtask.cli.argv", ["uvtask"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert "Usage: uvtask [COMMAND]" in captured.out - finally: - os.chdir(original_cwd) + mock_script_loader = MagicMock() + mock_script_loader.load_scripts_with_descriptions.return_value = ({"help": "echo help"}, {}) + mock_version_loader = MagicMock() + mock_parser_builder = MagicMock() + mock_argv_parser = MagicMock() + mock_validator = MagicMock() + mock_builder = MagicMock() + mock_help_handler = MagicMock() + mock_orchestrator = MagicMock() + + app = CliApplication( + mock_script_loader, + mock_version_loader, + mock_parser_builder, + mock_argv_parser, + mock_validator, + mock_builder, + mock_help_handler, + mock_orchestrator, + ) - def test_unknown_command(self, pyproject_toml: Path, capsys: pytest.CaptureFixture[str]) -> None: - """Test error for unknown command.""" - original_cwd = Path.cwd() - try: - os.chdir(pyproject_toml.parent) - with patch("uvtask.cli.argv", ["uvtask", "unknown-command"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "Error: Unknown command 'unknown-command'!" in captured.out - assert "Run 'uvtask --help' to see available commands." in captured.out + with pytest.raises(SystemExit) as exc_info: + app.run() + assert exc_info.value.code == 1 finally: os.chdir(original_cwd) - @patch("uvtask.cli.run") - def test_command_execution(self, mock_run: MagicMock, pyproject_toml: Path) -> None: - """Test that commands are executed correctly.""" + def test_run_executes_command(self, pyproject_toml: Path) -> None: original_cwd = Path.cwd() - mock_run.return_value = CompletedProcess(["echo", "test"], 0) try: - os.chdir(pyproject_toml.parent) - with patch("uvtask.cli.argv", ["uvtask", "test"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - mock_run.assert_called_once_with("echo test", check=False, shell=True) - finally: - os.chdir(original_cwd) + import os - @patch("uvtask.cli.run") - def test_command_with_args(self, mock_run: MagicMock, pyproject_toml: Path) -> None: - """Test command execution with additional arguments.""" - original_cwd = Path.cwd() - mock_run.return_value = CompletedProcess(["echo", "test"], 0) - try: os.chdir(pyproject_toml.parent) - with patch("uvtask.cli.argv", ["uvtask", "test", "--verbose", "arg1", "arg2"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - mock_run.assert_called_once_with("echo test --verbose arg1 arg2", check=False, shell=True) - finally: - os.chdir(original_cwd) + mock_script_loader = MagicMock() + mock_script_loader.load_scripts_with_descriptions.return_value = ( + {"test": "echo test"}, + {"test": "Test command"}, + ) + mock_version_loader = MagicMock() + mock_parser_builder = MagicMock() + mock_parser = MagicMock() + mock_parser_builder.build_main_parser.return_value = mock_parser + mock_argv_parser = MagicMock() + mock_argv_parser.parse_global_options.return_value = ("test", [], 0, 0) + mock_validator = MagicMock() + mock_builder = MagicMock() + mock_builder.build_commands.return_value = ["echo test"] + mock_help_handler = MagicMock() + mock_orchestrator = MagicMock() + mock_orchestrator.execute.side_effect = SystemExit(0) + + app = CliApplication( + mock_script_loader, + mock_version_loader, + mock_parser_builder, + mock_argv_parser, + mock_validator, + mock_builder, + mock_help_handler, + mock_orchestrator, + ) - def test_command_with_dollar_at_placeholder(self, temp_dir: Path) -> None: - """Test command with $@ placeholder for arguments.""" - original_cwd = Path.cwd() - pyproject_path = temp_dir / "pyproject.toml" - pyproject_content = """[tool.run-script] -format = "uv run ruff format $@" -""" - pyproject_path.write_text(pyproject_content) - mock_run = MagicMock(return_value=CompletedProcess(["uv", "run"], 0)) - try: - os.chdir(temp_dir) - with patch("uvtask.cli.run", mock_run): - with patch("uvtask.cli.argv", ["uvtask", "format", "src", "tests"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - # Note: $@ is not replaced, arguments are appended after the script - mock_run.assert_called_once_with("uv run ruff format $@ src tests", check=False, shell=True) - finally: - os.chdir(original_cwd) - - @patch("uvtask.cli.run") - def test_command_exit_code_propagation(self, mock_run: MagicMock, pyproject_toml: Path) -> None: - """Test that command exit codes are propagated.""" - original_cwd = Path.cwd() - mock_run.return_value = CompletedProcess(["echo", "test"], 42) - try: - os.chdir(pyproject_toml.parent) - with patch("uvtask.cli.argv", ["uvtask", "test"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 42 + with pytest.raises(SystemExit) as exc_info: + app.run() + assert exc_info.value.code == 0 + mock_orchestrator.execute.assert_called_once() finally: os.chdir(original_cwd) - @patch("uvtask.cli.run") - def test_keyboard_interrupt(self, mock_run: MagicMock, pyproject_toml: Path) -> None: - """Test KeyboardInterrupt handling.""" + def test_run_handles_help_command(self, pyproject_toml: Path) -> None: original_cwd = Path.cwd() - mock_run.side_effect = KeyboardInterrupt() try: - os.chdir(pyproject_toml.parent) - with patch("uvtask.cli.argv", ["uvtask", "test"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 130 - finally: - os.chdir(original_cwd) + import os - def test_multi_word_command(self, pyproject_toml: Path, capsys: pytest.CaptureFixture[str]) -> None: - """Test commands with multi-word names.""" - original_cwd = Path.cwd() - try: os.chdir(pyproject_toml.parent) - with patch("uvtask.cli.argv", ["uvtask", "-h"]): - with pytest.raises(SystemExit): - main() - captured = capsys.readouterr() - assert "multi-word" in captured.out - finally: - os.chdir(original_cwd) + mock_script_loader = MagicMock() + mock_script_loader.load_scripts_with_descriptions.return_value = ({"test": "echo test"}, {}) + mock_version_loader = MagicMock() + mock_parser_builder = MagicMock() + mock_parser = MagicMock() + mock_parser_builder.build_main_parser.return_value = mock_parser + mock_argv_parser = MagicMock() + mock_argv_parser.parse_global_options.return_value = ("help", [], 0, 0) + mock_validator = MagicMock() + mock_builder = MagicMock() + mock_help_handler = MagicMock() + mock_help_handler.handle_help.side_effect = SystemExit(0) + mock_orchestrator = MagicMock() + + app = CliApplication( + mock_script_loader, + mock_version_loader, + mock_parser_builder, + mock_argv_parser, + mock_validator, + mock_builder, + mock_help_handler, + mock_orchestrator, + ) - def test_empty_run_script_section(self, empty_pyproject_toml: Path, capsys: pytest.CaptureFixture[str]) -> None: - """Test behavior when run-script section is missing.""" - original_cwd = Path.cwd() - try: - os.chdir(empty_pyproject_toml.parent) - with patch("uvtask.cli.argv", ["uvtask", "-h"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - captured = capsys.readouterr() - # Should show help with no commands listed - assert "Usage: uvtask [COMMAND]" in captured.out + with pytest.raises(SystemExit) as exc_info: + app.run() + assert exc_info.value.code == 0 + mock_help_handler.handle_help.assert_called_once() finally: os.chdir(original_cwd) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py new file mode 100644 index 0000000..dbd561d --- /dev/null +++ b/tests/unit/test_commands.py @@ -0,0 +1,194 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from uvtask.commands import ( + CommandBuilder, + CommandExecutorOrchestrator, + CommandValidator, + HelpCommandHandler, + VerboseOutputHandler, +) + + +class TestCommandBuilder: + def test_build_string_command(self) -> None: + builder = CommandBuilder() + commands = builder.build_commands("echo hello", ["world"]) + assert commands == ["echo hello world"] + + def test_build_string_command_no_args(self) -> None: + builder = CommandBuilder() + commands = builder.build_commands("echo hello", []) + assert commands == ["echo hello"] + + def test_build_list_commands(self) -> None: + builder = CommandBuilder() + commands = builder.build_commands(["echo hello", "echo world"], ["test"]) + assert commands == ["echo hello test", "echo world test"] + + def test_build_list_commands_no_args(self) -> None: + builder = CommandBuilder() + commands = builder.build_commands(["echo hello", "echo world"], []) + assert commands == ["echo hello", "echo world"] + + def test_build_with_command_reference(self) -> None: + builder = CommandBuilder() + scripts = {"lint": "ruff check .", "test": "pytest", "check": ["lint", "test"]} + commands = builder.build_commands("check", [], scripts) + assert "ruff check ." in commands + assert "pytest" in commands + + def test_build_with_nested_references(self) -> None: + builder = CommandBuilder() + scripts = {"lint": "ruff check .", "format": "ruff format .", "static": ["lint", "format"], "all": ["static"]} + commands = builder.build_commands("all", [], scripts) + assert "ruff check ." in commands + assert "ruff format ." in commands + + def test_build_circular_reference_detection(self) -> None: + builder = CommandBuilder() + scripts = {"a": "b", "b": "a"} + with pytest.raises(ValueError, match="Circular reference"): + builder.build_commands("a", [], scripts) + + def test_build_invalid_type(self) -> None: + builder = CommandBuilder() + with pytest.raises(ValueError, match="Invalid script format"): + builder.build_commands(123, []) # type: ignore[arg-type] + + +class TestCommandValidator: + def test_validate_exists(self) -> None: + validator = CommandValidator() + scripts = {"test": "echo test"} + validator.validate_exists("test", scripts) + + def test_validate_not_exists_raises(self) -> None: + validator = CommandValidator() + scripts = {"test": "echo test"} + with pytest.raises(SystemExit) as exc_info: + validator.validate_exists("unknown", scripts) + assert exc_info.value.code == 1 + + +class TestHelpCommandHandler: + def test_handle_help_general(self) -> None: + mock_parser = MagicMock() + handler = HelpCommandHandler() + with pytest.raises(SystemExit) as exc_info: + handler.handle_help(None, {}, {}, mock_parser) + assert exc_info.value.code == 0 + mock_parser.print_help.assert_called_once() + + def test_handle_help_specific_command(self, capsys: pytest.CaptureFixture[str]) -> None: + scripts = {"test": "echo test"} + descriptions = {"test": "Test command"} + mock_parser = MagicMock() + handler = HelpCommandHandler() + with pytest.raises(SystemExit) as exc_info: + handler.handle_help("test", scripts, descriptions, mock_parser) + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "Test command" in captured.out + + def test_handle_help_unknown_command(self) -> None: + scripts = {"test": "echo test"} + mock_parser = MagicMock() + handler = HelpCommandHandler() + with pytest.raises(SystemExit) as exc_info: + handler.handle_help("unknown", scripts, {}, mock_parser) + assert exc_info.value.code == 1 + + +class TestVerboseOutputHandler: + @patch("uvtask.commands.color_service") + def test_show_execution_info(self, mock_color: MagicMock) -> None: + mock_color.bold_green.side_effect = lambda x: x + VerboseOutputHandler.show_execution_info("test", ["echo test"], ["pre"], ["post"], 1) + assert mock_color.bold_green.called + + def test_show_execution_info_no_verbose(self, capsys: pytest.CaptureFixture[str]) -> None: + VerboseOutputHandler.show_execution_info("test", ["echo test"], [], [], 0) + captured = capsys.readouterr() + assert "Command: test" not in captured.err + + @patch("uvtask.commands.color_service") + def test_show_hook_failure(self, mock_color: MagicMock) -> None: + mock_color.bold_red.side_effect = lambda x: x + VerboseOutputHandler.show_hook_failure("Pre-hook", 1, 1) + assert mock_color.bold_red.called + + @patch("uvtask.commands.color_service") + def test_show_command_failure(self, mock_color: MagicMock) -> None: + mock_color.bold_red.side_effect = lambda x: x + VerboseOutputHandler.show_command_failure(1, 1) + assert mock_color.bold_red.called + + @patch("uvtask.commands.color_service") + def test_show_final_exit_code(self, mock_color: MagicMock) -> None: + mock_color.bold_green.side_effect = lambda x: x + mock_color.bold_red.side_effect = lambda x: x + VerboseOutputHandler.show_final_exit_code(0, 1) + assert mock_color.bold_green.called + + +class TestCommandExecutorOrchestrator: + def test_execute_success(self) -> None: + mock_executor = MagicMock() + mock_executor.execute.return_value = 0 + orchestrator = CommandExecutorOrchestrator(mock_executor) + with pytest.raises(SystemExit) as exc_info: + orchestrator.execute("test", ["echo test"], [], [], 0, 0) + assert exc_info.value.code == 0 + mock_executor.execute.assert_called_once_with("echo test", 0, 0) + + def test_execute_with_pre_hooks(self) -> None: + mock_executor = MagicMock() + mock_executor.execute.return_value = 0 + orchestrator = CommandExecutorOrchestrator(mock_executor) + with pytest.raises(SystemExit) as exc_info: + orchestrator.execute("test", ["echo test"], ["echo pre"], [], 0, 0) + assert exc_info.value.code == 0 + assert mock_executor.execute.call_count == 2 + + def test_execute_pre_hook_failure(self) -> None: + mock_executor = MagicMock() + mock_executor.execute.return_value = 1 + orchestrator = CommandExecutorOrchestrator(mock_executor) + with pytest.raises(SystemExit) as exc_info: + orchestrator.execute("test", ["echo test"], ["echo pre"], [], 0, 0) + assert exc_info.value.code == 1 + + def test_execute_main_command_failure(self) -> None: + mock_executor = MagicMock() + mock_executor.execute.side_effect = [0, 1] + orchestrator = CommandExecutorOrchestrator(mock_executor) + with pytest.raises(SystemExit) as exc_info: + orchestrator.execute("test", ["echo test"], ["echo pre"], [], 0, 0) + assert exc_info.value.code == 1 + + def test_execute_with_post_hooks(self) -> None: + mock_executor = MagicMock() + mock_executor.execute.side_effect = [0, 0, 0] + orchestrator = CommandExecutorOrchestrator(mock_executor) + with pytest.raises(SystemExit) as exc_info: + orchestrator.execute("test", ["echo test"], ["echo pre"], ["echo post"], 0, 0) + assert exc_info.value.code == 0 + assert mock_executor.execute.call_count == 3 + + def test_execute_post_hook_failure(self) -> None: + mock_executor = MagicMock() + mock_executor.execute.side_effect = [0, 0, 1] + orchestrator = CommandExecutorOrchestrator(mock_executor) + with pytest.raises(SystemExit) as exc_info: + orchestrator.execute("test", ["echo test"], ["echo pre"], ["echo post"], 0, 0) + assert exc_info.value.code == 1 + + def test_execute_keyboard_interrupt(self) -> None: + mock_executor = MagicMock() + mock_executor.execute.side_effect = KeyboardInterrupt() + orchestrator = CommandExecutorOrchestrator(mock_executor) + with pytest.raises(SystemExit) as exc_info: + orchestrator.execute("test", ["echo test"], [], [], 0, 0) + assert exc_info.value.code == 130 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..ca86ab0 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,137 @@ +from pathlib import Path + +import pytest + +from uvtask.config import ( + PyProjectReader, + RunScriptSectionReader, + ScriptLoader, + ScriptValueParser, + VersionLoader, +) + + +class TestPyProjectReader: + def test_exists_true(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path.write_text("[project]\nname = 'test'") + reader = PyProjectReader(pyproject_path) + assert reader.exists() is True + + def test_exists_false(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "nonexistent.toml" + reader = PyProjectReader(pyproject_path) + assert reader.exists() is False + + def test_read_existing_file(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path.write_text("[project]\nname = 'test'\nversion = '1.0.0'") + reader = PyProjectReader(pyproject_path) + data = reader.read() + assert data["project"]["name"] == "test" + assert data["project"]["version"] == "1.0.0" + + def test_read_nonexistent_file(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "nonexistent.toml" + reader = PyProjectReader(pyproject_path) + data = reader.read() + assert data == {} + + +class TestScriptValueParser: + def test_parse_string(self) -> None: + command, description = ScriptValueParser.parse("test", "echo hello") + assert command == "echo hello" + assert description == "" + + def test_parse_list(self) -> None: + command, description = ScriptValueParser.parse("test", ["echo hello", "echo world"]) + assert command == ["echo hello", "echo world"] + assert description == "" + + def test_parse_dict_with_command_string(self) -> None: + command, description = ScriptValueParser.parse("test", {"command": "echo hello", "description": "Test command"}) + assert command == "echo hello" + assert description == "Test command" + + def test_parse_dict_with_multiline_command(self) -> None: + multiline_cmd = """python3 -c " +from glob import iglob +from shutil import rmtree + +for pathname in ['./build', './*.egg-info']: + for path in iglob(pathname, recursive=True): + rmtree(path, ignore_errors=True) +" +""" + command, description = ScriptValueParser.parse("clean", {"command": multiline_cmd, "description": "Clean build artifacts"}) + assert "\n" in command + assert "python3 -c" in command + assert "from glob import iglob" in command + assert description == "Clean build artifacts" + + def test_parse_dict_with_command_list(self) -> None: + command, description = ScriptValueParser.parse("test", {"command": ["echo hello", "echo world"], "description": "Test commands"}) + assert command == ["echo hello", "echo world"] + assert description == "Test commands" + + def test_parse_dict_without_description(self) -> None: + command, description = ScriptValueParser.parse("test", {"command": "echo hello"}) + assert command == "echo hello" + assert description == "" + + def test_parse_dict_without_command(self) -> None: + command, description = ScriptValueParser.parse("test", {"description": "Test"}) + assert command == "{'description': 'Test'}" + assert description == "" + + def test_parse_invalid_type(self) -> None: + with pytest.raises(ValueError, match="Invalid script value"): + ScriptValueParser.parse("test", 123) # type: ignore[arg-type] + + +class TestRunScriptSectionReader: + def test_get_uvtask_namespace(self) -> None: + tool_section = {"uvtask": {"run-script": {"test": "echo test"}}} + result = RunScriptSectionReader.get_run_script_section(tool_section) + assert result == {"test": "echo test"} + + def test_get_tool_namespace(self) -> None: + tool_section = {"run-script": {"test": "echo test"}} + result = RunScriptSectionReader.get_run_script_section(tool_section) + assert result == {"test": "echo test"} + + def test_uvtask_takes_precedence(self) -> None: + tool_section = { + "uvtask": {"run-script": {"test": "uvtask version"}}, + "run-script": {"test": "tool version"}, + } + result = RunScriptSectionReader.get_run_script_section(tool_section) + assert result == {"test": "uvtask version"} + + def test_empty_section(self) -> None: + tool_section = {} + result = RunScriptSectionReader.get_run_script_section(tool_section) + assert result == {} + + +class TestScriptLoader: + def test_load_scripts_with_descriptions(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path = temp_dir / "nonexistent.toml" + reader = PyProjectReader(pyproject_path) + loader = ScriptLoader(reader, ScriptValueParser(), RunScriptSectionReader()) + scripts, descriptions = loader.load_scripts_with_descriptions() + assert scripts == {} + assert descriptions == {} + + +class TestVersionLoader: + def test_get_version(self, temp_dir: Path) -> None: + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path = temp_dir / "pyproject.toml" + pyproject_path = temp_dir / "nonexistent.toml" + reader = PyProjectReader(pyproject_path) + loader = VersionLoader(reader) + assert loader.get_version() == "unknown" diff --git a/tests/unit/test_executor.py b/tests/unit/test_executor.py new file mode 100644 index 0000000..54f3996 --- /dev/null +++ b/tests/unit/test_executor.py @@ -0,0 +1,72 @@ +from subprocess import CompletedProcess +from unittest.mock import MagicMock, patch + +from uvtask.executor import CommandExecutor + + +class TestCommandExecutor: + @patch("uvtask.executor.run") + def test_execute_success(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess(["echo", "test"], 0) + executor = CommandExecutor() + exit_code = executor.execute("echo test", 0, 0) + assert exit_code == 0 + mock_run.assert_called_once() + + @patch("uvtask.executor.subprocess.run") + def test_execute_with_quiet(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess(["echo", "test"], 0) + executor = CommandExecutor() + exit_code = executor.execute("echo test", 1, 0) + assert exit_code == 0 + call_kwargs = mock_run.call_args[1] + assert call_kwargs["stdout"] is not None or call_kwargs.get("stdout") is None + + @patch("uvtask.executor.subprocess.run") + def test_execute_with_double_quiet(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess(["echo", "test"], 0) + executor = CommandExecutor() + exit_code = executor.execute("echo test", 2, 0) + assert exit_code == 0 + call_kwargs = mock_run.call_args[1] + assert call_kwargs["stderr"] is not None or call_kwargs.get("stderr") is None + + @patch("uvtask.executor.run") + @patch("uvtask.executor.color_service") + @patch("uvtask.executor.preference_manager") + def test_execute_with_verbose(self, mock_pref: MagicMock, mock_color: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess(["echo", "test"], 0) + mock_pref.supports_color.return_value = True + mock_color.bold_teal.side_effect = lambda x: x + mock_color.bold_green.side_effect = lambda x: x + executor = CommandExecutor() + exit_code = executor.execute("echo test", 0, 1) + assert exit_code == 0 + assert mock_color.bold_teal.called or mock_color.bold_green.called + + @patch("uvtask.executor.run") + def test_execute_failure(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess(["echo", "test"], 1) + executor = CommandExecutor() + exit_code = executor.execute("echo test", 0, 0) + assert exit_code == 1 + + @patch("uvtask.executor.run") + def test_execute_keyboard_interrupt(self, mock_run: MagicMock) -> None: + mock_run.side_effect = KeyboardInterrupt() + executor = CommandExecutor() + exit_code = executor.execute("echo test", 0, 0) + assert exit_code == 130 + + @patch("uvtask.executor.run") + @patch("uvtask.executor.color_service") + @patch("uvtask.executor.preference_manager") + def test_execute_verbose_shows_exit_code(self, mock_pref: MagicMock, mock_color: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess(["echo", "test"], 42) + mock_pref.supports_color.return_value = True + mock_color.bold_teal.side_effect = lambda x: x + mock_color.bold_red.side_effect = lambda x: x + executor = CommandExecutor() + exit_code = executor.execute("echo test", 0, 1) + assert exit_code == 42 + assert mock_color.bold_teal.called or mock_color.bold_red.called diff --git a/tests/unit/test_formatters.py b/tests/unit/test_formatters.py new file mode 100644 index 0000000..acd2b3f --- /dev/null +++ b/tests/unit/test_formatters.py @@ -0,0 +1,120 @@ +import argparse +from unittest.mock import MagicMock, patch + +import pytest + +from uvtask.formatters import ( + AnsiStripper, + CommandMatcher, + CustomArgumentParser, + CustomHelpFormatter, + HelpTextProcessor, + OptionSorter, +) + + +class TestCommandMatcher: + def test_find_similar_exact_match(self) -> None: + matcher = CommandMatcher() + result = matcher.find_similar("test", ["test", "build", "lint"]) + assert result == "test" + + def test_find_similar_prefix(self) -> None: + matcher = CommandMatcher() + result = matcher.find_similar("tes", ["test", "build"]) + assert result == "test" + + def test_find_similar_levenshtein(self) -> None: + matcher = CommandMatcher() + result = matcher.find_similar("buil", ["build", "test"]) + assert result == "build" + + def test_find_similar_no_match(self) -> None: + matcher = CommandMatcher() + result = matcher.find_similar("xyz", ["test", "build"]) + assert result is None + + def test_find_similar_empty_list(self) -> None: + matcher = CommandMatcher() + result = matcher.find_similar("test", []) + assert result is None + + +class TestAnsiStripper: + def test_strip_ansi_codes(self) -> None: + text = "\x1b[1m\x1b[31mHello\x1b[0m" + result = AnsiStripper.strip(text) + assert result == "Hello" + + def test_strip_no_codes(self) -> None: + text = "Hello" + result = AnsiStripper.strip(text) + assert result == "Hello" + + +class TestOptionSorter: + def test_sort_options(self) -> None: + lines = [ + " -h, --help", + " -V, --version", + " -q, --quiet", + " -v, --verbose", + ] + result = OptionSorter.sort(lines) + assert "-q" in result[0] or "--quiet" in result[0] + assert "-h" in result[-1] or "--help" in result[-1] + + +class TestHelpTextProcessor: + def test_process_help_text(self) -> None: + processor = HelpTextProcessor(AnsiStripper()) + help_text = "Description\n\nCommands:\n test\n\nGlobal options:\n -h, --help" + result = processor.process_help_text(help_text) + assert "Usage:" in result or "Commands:" in result + + def test_add_usage_line(self) -> None: + processor = HelpTextProcessor(AnsiStripper()) + result = [] + processor._add_usage_line(result) + assert len(result) == 2 + assert "Usage:" in result[0] + + +class TestCustomHelpFormatter: + def test_format_action_invocation_option(self) -> None: + formatter = CustomHelpFormatter(prog="test") + action = argparse.Action("--test", dest="test", help="Test option") + action.option_strings = ["--test"] + result = formatter._format_action_invocation(action) + assert "--test" in result + + def test_format_action_invocation_count_action(self) -> None: + formatter = CustomHelpFormatter(prog="test") + action = argparse._CountAction("--quiet", dest="quiet", help="Quiet") + action.option_strings = ["-q", "--quiet"] + result = formatter._format_action_invocation(action) + assert "..." in result + + def test_get_metavar_str(self) -> None: + formatter = CustomHelpFormatter(prog="test") + action = argparse.Action("--color", dest="color", help="Color") + action.option_strings = ["--color"] + action.choices = ["auto", "always", "never"] + result = formatter._get_metavar_str(action) + assert result == "COLOR_CHOICE" + + +class TestCustomArgumentParser: + def test_init(self) -> None: + parser = CustomArgumentParser(prog="test") + assert parser.prog == "test" + assert isinstance(parser._command_matcher, CommandMatcher) + + @patch("uvtask.formatters.preference_manager") + def test_error_invalid_choice(self, mock_pref: MagicMock) -> None: + parser = CustomArgumentParser(prog="test") + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("test") + with pytest.raises(SystemExit) as exc_info: + parser.error("argument COMMAND: invalid choice: 'unknown' (choose from 'test')") + assert exc_info.value.code == 1 diff --git a/tests/unit/test_hooks.py b/tests/unit/test_hooks.py new file mode 100644 index 0000000..0b5803a --- /dev/null +++ b/tests/unit/test_hooks.py @@ -0,0 +1,117 @@ +from unittest.mock import patch + +import pytest + +from uvtask.hooks import ( + ArgvHookFlagParser, + HookCommandExtractor, + HookDiscoverer, + HookNameGenerator, + HookStyleValidator, +) + + +class TestHookNameGenerator: + def test_composer_names(self) -> None: + pre, post = HookNameGenerator.composer_names("test") + assert pre == "pre-test" + assert post == "post-test" + + def test_npm_names(self) -> None: + pre, post = HookNameGenerator.npm_names("test") + assert pre == "pretest" + assert post == "posttest" + + +class TestHookCommandExtractor: + def test_extract_string(self) -> None: + commands = HookCommandExtractor.extract_commands("echo hello") + assert commands == ["echo hello"] + + def test_extract_list(self) -> None: + commands = HookCommandExtractor.extract_commands(["echo hello", "echo world"]) + assert commands == ["echo hello", "echo world"] + + +class TestHookStyleValidator: + def test_validate_consistent_composer(self) -> None: + validator = HookStyleValidator(HookNameGenerator()) + validator.validate_consistency("test", True, True, False, False) + + def test_validate_consistent_npm(self) -> None: + validator = HookStyleValidator(HookNameGenerator()) + validator.validate_consistency("test", False, False, True, True) + + def test_validate_mixed_styles_raises(self) -> None: + validator = HookStyleValidator(HookNameGenerator()) + with pytest.raises(SystemExit) as exc_info: + validator.validate_consistency("test", True, False, False, True) + assert exc_info.value.code == 1 + + def test_validate_no_hooks(self) -> None: + validator = HookStyleValidator(HookNameGenerator()) + validator.validate_consistency("test", False, False, False, False) + + +class TestHookDiscoverer: + def test_discover_composer_pre_post(self) -> None: + scripts = {"pre-test": "echo pre", "post-test": "echo post", "test": "echo test"} + discoverer = HookDiscoverer(HookNameGenerator(), HookStyleValidator(HookNameGenerator()), HookCommandExtractor()) + pre_hooks, post_hooks = discoverer.discover("test", scripts) + assert pre_hooks == ["echo pre"] + assert post_hooks == ["echo post"] + + def test_discover_npm_pre_post(self) -> None: + scripts = {"pretest": "echo pre", "posttest": "echo post", "test": "echo test"} + discoverer = HookDiscoverer(HookNameGenerator(), HookStyleValidator(HookNameGenerator()), HookCommandExtractor()) + pre_hooks, post_hooks = discoverer.discover("test", scripts) + assert pre_hooks == ["echo pre"] + assert post_hooks == ["echo post"] + + def test_discover_only_pre(self) -> None: + scripts = {"pre-test": "echo pre", "test": "echo test"} + discoverer = HookDiscoverer(HookNameGenerator(), HookStyleValidator(HookNameGenerator()), HookCommandExtractor()) + pre_hooks, post_hooks = discoverer.discover("test", scripts) + assert pre_hooks == ["echo pre"] + assert post_hooks == [] + + def test_discover_only_post(self) -> None: + scripts = {"post-test": "echo post", "test": "echo test"} + discoverer = HookDiscoverer(HookNameGenerator(), HookStyleValidator(HookNameGenerator()), HookCommandExtractor()) + pre_hooks, post_hooks = discoverer.discover("test", scripts) + assert pre_hooks == [] + assert post_hooks == ["echo post"] + + def test_discover_list_hooks(self) -> None: + scripts = {"pre-test": ["echo pre1", "echo pre2"], "test": "echo test"} + discoverer = HookDiscoverer(HookNameGenerator(), HookStyleValidator(HookNameGenerator()), HookCommandExtractor()) + pre_hooks, post_hooks = discoverer.discover("test", scripts) + assert pre_hooks == ["echo pre1", "echo pre2"] + assert post_hooks == [] + + def test_discover_mixed_styles_raises(self) -> None: + scripts = {"pre-test": "echo pre", "posttest": "echo post", "test": "echo test"} + discoverer = HookDiscoverer(HookNameGenerator(), HookStyleValidator(HookNameGenerator()), HookCommandExtractor()) + with pytest.raises(SystemExit): + discoverer.discover("test", scripts) + + def test_discover_no_hooks(self) -> None: + scripts = {"test": "echo test"} + discoverer = HookDiscoverer(HookNameGenerator(), HookStyleValidator(HookNameGenerator()), HookCommandExtractor()) + pre_hooks, post_hooks = discoverer.discover("test", scripts) + assert pre_hooks == [] + assert post_hooks == [] + + +class TestArgvHookFlagParser: + @patch("uvtask.hooks.argv", ["uvtask", "--no-hooks", "test"]) + def test_parse_no_hooks_flag(self) -> None: + assert ArgvHookFlagParser.parse_no_hooks() is True + + @patch("uvtask.hooks.argv", ["uvtask", "--ignore-scripts", "test"]) + def test_parse_ignore_scripts_flag(self) -> None: + assert ArgvHookFlagParser.parse_no_hooks() is True + + @patch("uvtask.hooks.argv", ["uvtask", "test"]) + def test_parse_no_flag(self) -> None: + assert ArgvHookFlagParser.parse_no_hooks() is False diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py new file mode 100644 index 0000000..51d4558 --- /dev/null +++ b/tests/unit/test_parser.py @@ -0,0 +1,129 @@ +import argparse +from unittest.mock import MagicMock, patch + +from uvtask.parser import ArgumentParserBuilder, ArgvParser + + +class TestArgvParser: + def test_parse_global_options_simple_command(self) -> None: + parser = ArgvParser(["uvtask", "test"]) + scripts = {"test": "echo test"} + command, args, quiet, verbose = parser.parse_global_options(scripts) + assert command == "test" + assert args == [] + assert quiet == 0 + assert verbose == 0 + + def test_parse_global_options_with_args(self) -> None: + parser = ArgvParser(["uvtask", "test", "arg1", "arg2"]) + scripts = {"test": "echo test"} + command, args, quiet, verbose = parser.parse_global_options(scripts) + assert command == "test" + assert args == ["arg1", "arg2"] + assert quiet == 0 + assert verbose == 0 + + def test_parse_quiet_flag(self) -> None: + parser = ArgvParser(["uvtask", "-q", "test"]) + scripts = {"test": "echo test"} + _command, _args, quiet, verbose = parser.parse_global_options(scripts) + assert quiet == 1 + assert verbose == 0 + + def test_parse_multiple_quiet_flags(self) -> None: + parser = ArgvParser(["uvtask", "-q", "-q", "test"]) + scripts = {"test": "echo test"} + _command, _args, quiet, verbose = parser.parse_global_options(scripts) + assert quiet == 2 + assert verbose == 0 + + def test_parse_verbose_flag(self) -> None: + parser = ArgvParser(["uvtask", "-v", "test"]) + scripts = {"test": "echo test"} + _command, _args, quiet, verbose = parser.parse_global_options(scripts) + assert quiet == 0 + assert verbose == 1 + + def test_parse_multiple_verbose_flags(self) -> None: + parser = ArgvParser(["uvtask", "-v", "-v", "test"]) + scripts = {"test": "echo test"} + _command, _args, quiet, verbose = parser.parse_global_options(scripts) + assert quiet == 0 + assert verbose == 2 + + @patch("uvtask.parser.preference_manager") + def test_parse_color_flag(self, mock_pref: MagicMock) -> None: + parser = ArgvParser(["uvtask", "--color", "never", "test"]) + scripts = {"test": "echo test"} + command, _args, _quiet, _verbose = parser.parse_global_options(scripts) + assert command == "test" + mock_pref.set_preference_from_string.assert_called_once_with("never") + + def test_parse_color_equals(self) -> None: + parser = ArgvParser(["uvtask", "--color=always", "test"]) + scripts = {"test": "echo test"} + with patch("uvtask.parser.preference_manager") as mock_pref: + command, _args, _quiet, _verbose = parser.parse_global_options(scripts) + assert command == "test" + mock_pref.set_preference_from_string.assert_called_once_with("always") + + def test_parse_help_command(self) -> None: + parser = ArgvParser(["uvtask", "help", "test"]) + scripts = {"test": "echo test"} + command, args, _quiet, _verbose = parser.parse_global_options(scripts) + assert command == "help" + assert args == ["test"] + + def test_parse_no_command(self) -> None: + parser = ArgvParser(["uvtask", "--version"]) + scripts = {} + command, args, _quiet, _verbose = parser.parse_global_options(scripts) + assert command is None + assert args == [] + + +class TestArgumentParserBuilder: + def test_build_main_parser(self) -> None: + mock_version_loader = MagicMock() + mock_version_loader.get_version.return_value = "1.0.0" + builder = ArgumentParserBuilder(mock_version_loader) + parser = builder.build_main_parser() + assert parser.prog == "uvtask" + assert parser.description == "An extremely fast Python task runner." + + def test_add_subparsers(self) -> None: + mock_version_loader = MagicMock() + mock_version_loader.get_version.return_value = "1.0.0" + builder = ArgumentParserBuilder(mock_version_loader) + parser = builder.build_main_parser() + scripts = {"test": "echo test", "build": "echo build"} + descriptions = {"test": "Test command", "build": "Build command"} + builder.add_subparsers(parser, scripts, descriptions) + subparsers_action = None + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + subparsers_action = action + break + assert subparsers_action is not None + assert "test" in subparsers_action.choices + assert "build" in subparsers_action.choices + assert "help" in subparsers_action.choices + + def test_add_subparsers_skips_hooks(self) -> None: + mock_version_loader = MagicMock() + mock_version_loader.get_version.return_value = "1.0.0" + builder = ArgumentParserBuilder(mock_version_loader) + parser = builder.build_main_parser() + scripts = {"test": "echo test", "pre-test": "echo pre", "post-test": "echo post"} + descriptions = {} + builder.add_subparsers(parser, scripts, descriptions) + subparsers_action = None + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + subparsers_action = action + break + assert subparsers_action is not None + choices = subparsers_action.choices + assert "test" in choices + assert "pre-test" not in choices + assert "post-test" not in choices diff --git a/uv.lock b/uv.lock index f5c872c..c60a637 100644 --- a/uv.lock +++ b/uv.lock @@ -306,31 +306,32 @@ wheels = [ [[package]] name = "ty" -version = "0.0.5" +version = "0.0.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/db/6299d478000f4f1c6f9bf2af749359381610ffc4cbe6713b66e436ecf6e7/ty-0.0.5.tar.gz", hash = "sha256:983da6330773ff71e2b249810a19c689f9a0372f6e21bbf7cde37839d05b4346", size = 4806218, upload-time = "2025-12-20T21:19:17.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/43/8be3ec2e2ce6119cff9ee3a207fae0cb4f2b4f8ed6534175130a32be24a7/ty-0.0.7.tar.gz", hash = "sha256:90e53b20b86c418ee41a8385f17da44cc7f916f96f9eee87593423ce8292ca72", size = 4826677, upload-time = "2025-12-24T21:28:49.136Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/c1f61ba378b4191e641bb36c07b7fcc70ff844d61be7a4bf2fea7472b4a9/ty-0.0.5-py3-none-linux_armv6l.whl", hash = "sha256:1594cd9bb68015eb2f5a3c68a040860f3c9306dc6667d7a0e5f4df9967b460e2", size = 9785554, upload-time = "2025-12-20T21:19:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f9/b37b77c03396bd779c1397dae4279b7ad79315e005b3412feed8812a4256/ty-0.0.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7c0140ba980233d28699d9ddfe8f43d0b3535d6a3bbff9935df625a78332a3cf", size = 9603995, upload-time = "2025-12-20T21:19:15.256Z" }, - { url = "https://files.pythonhosted.org/packages/7d/70/4e75c11903b0e986c0203040472627cb61d6a709e1797fb08cdf9d565743/ty-0.0.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:15de414712cde92048ae4b1a77c4dc22920bd23653fe42acaf73028bad88f6b9", size = 9145815, upload-time = "2025-12-20T21:19:36.481Z" }, - { url = "https://files.pythonhosted.org/packages/89/05/93983dfcf871a41dfe58e5511d28e6aa332a1f826cc67333f77ae41a2f8a/ty-0.0.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:438aa51ad6c5fae64191f8d58876266e26f9250cf09f6624b6af47a22fa88618", size = 9619849, upload-time = "2025-12-20T21:19:19.084Z" }, - { url = "https://files.pythonhosted.org/packages/82/b6/896ab3aad59f846823f202e94be6016fb3f72434d999d2ae9bd0f28b3af9/ty-0.0.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b3d373fd96af1564380caf153600481c676f5002ee76ba8a7c3508cdff82ee0", size = 9606611, upload-time = "2025-12-20T21:19:24.583Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ae/098e33fc92330285ed843e2750127e896140c4ebd2d73df7732ea496f588/ty-0.0.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8453692503212ad316cf8b99efbe85a91e5f63769c43be5345e435a1b16cba5a", size = 10029523, upload-time = "2025-12-20T21:19:07.055Z" }, - { url = "https://files.pythonhosted.org/packages/04/5a/f4b4c33758b9295e9aca0de9645deca0f4addd21d38847228723a6e780fc/ty-0.0.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2e4c454139473abbd529767b0df7a795ed828f780aef8d0d4b144558c0dc4446", size = 10870892, upload-time = "2025-12-20T21:19:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c5/4e3e7e88389365aa1e631c99378711cf0c9d35a67478cb4720584314cf44/ty-0.0.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:426d4f3b82475b1ec75f3cc9ee5a667c8a4ae8441a09fcd8e823a53b706d00c7", size = 10599291, upload-time = "2025-12-20T21:19:26.557Z" }, - { url = "https://files.pythonhosted.org/packages/c1/5d/138f859ea87bd95e17b9818e386ae25a910e46521c41d516bf230ed83ffc/ty-0.0.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5710817b67c6b2e4c0224e4f319b7decdff550886e9020f6d46aa1ce8f89a609", size = 10413515, upload-time = "2025-12-20T21:19:11.094Z" }, - { url = "https://files.pythonhosted.org/packages/27/21/1cbcd0d3b1182172f099e88218137943e0970603492fb10c7c9342369d9a/ty-0.0.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23c55ef08882c7c5ced1ccb90b4eeefa97f690aea254f58ac0987896c590f76", size = 10144992, upload-time = "2025-12-20T21:19:13.225Z" }, - { url = "https://files.pythonhosted.org/packages/ad/30/fdac06a5470c09ad2659a0806497b71f338b395d59e92611f71b623d05a0/ty-0.0.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b9e4c1a28a23b14cf8f4f793f4da396939f16c30bfa7323477c8cc234e352ac4", size = 9606408, upload-time = "2025-12-20T21:19:09.212Z" }, - { url = "https://files.pythonhosted.org/packages/09/93/e99dcd7f53295192d03efd9cbcec089a916f49cad4935c0160ea9adbd53d/ty-0.0.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4e9ebb61529b9745af662e37c37a01ad743cdd2c95f0d1421705672874d806cd", size = 9630040, upload-time = "2025-12-20T21:19:38.165Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f8/6d1e87186e4c35eb64f28000c1df8fd5f73167ce126c5e3dd21fd1204a23/ty-0.0.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5eb191a8e332f50f56dfe45391bdd7d43dd4ef6e60884710fd7ce84c5d8c1eb5", size = 9754016, upload-time = "2025-12-20T21:19:32.79Z" }, - { url = "https://files.pythonhosted.org/packages/28/e6/20f989342cb3115852dda404f1d89a10a3ce93f14f42b23f095a3d1a00c9/ty-0.0.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:92ed7451a1e82ee134a2c24ca43b74dd31e946dff2b08e5c34473e6b051de542", size = 10252877, upload-time = "2025-12-20T21:19:20.787Z" }, - { url = "https://files.pythonhosted.org/packages/57/9d/fc66fa557443233dfad9ae197ff3deb70ae0efcfb71d11b30ef62f5cdcc3/ty-0.0.5-py3-none-win32.whl", hash = "sha256:71f6707e4c1c010c158029a688a498220f28bb22fdb6707e5c20e09f11a5e4f2", size = 9212640, upload-time = "2025-12-20T21:19:30.817Z" }, - { url = "https://files.pythonhosted.org/packages/68/b6/05c35f6dea29122e54af0e9f8dfedd0a100c721affc8cc801ebe2bc2ed13/ty-0.0.5-py3-none-win_amd64.whl", hash = "sha256:2b8b754a0d7191e94acdf0c322747fec34371a4d0669f5b4e89549aef28814ae", size = 10034701, upload-time = "2025-12-20T21:19:28.311Z" }, - { url = "https://files.pythonhosted.org/packages/df/ca/4201ed5cb2af73912663d0c6ded927c28c28b3c921c9348aa8d2cfef4853/ty-0.0.5-py3-none-win_arm64.whl", hash = "sha256:83bea5a5296caac20d52b790ded2b830a7ff91c4ed9f36730fe1f393ceed6654", size = 9566474, upload-time = "2025-12-20T21:19:22.518Z" }, + { url = "https://files.pythonhosted.org/packages/6e/56/fafa123acf955089306372add312f16e97aba61f7c4daf74e2bb9c350d23/ty-0.0.7-py3-none-linux_armv6l.whl", hash = "sha256:b30105bd9a0b064497111c50c206d5b6a032f29bcf39f09a12085c3009d72784", size = 9862360, upload-time = "2025-12-24T21:28:36.762Z" }, + { url = "https://files.pythonhosted.org/packages/71/f4/9c30ff498d9a60e24f16d26c0cf93cd03a119913ffa720a77149f02df06e/ty-0.0.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b4df20889115f3d5611a9d9cdedc222e3fd82b5fe87bb0a9f7246e53a23becc7", size = 9712866, upload-time = "2025-12-24T21:28:25.926Z" }, + { url = "https://files.pythonhosted.org/packages/43/84/e06a4a6e4011890027ffee41efbf261b1335103d09009d625ace7f1a60eb/ty-0.0.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f699589d8511e1e17c5a7edfc5f4a4e80f2a6d4a3932a0e9e3422fd32d731472", size = 9221692, upload-time = "2025-12-24T21:28:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e9/ebb4192d3627730125d40ee403a17dc91bab59d69c3eff286453b3218d01/ty-0.0.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3eaec2d8aa153ee4bcc43b17a384d0f9e66177c8c8127be3358b6b8348b9e3b", size = 9710340, upload-time = "2025-12-24T21:28:55.148Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4a/ec144458a9cfb324d5cb471483094e62e74d73179343dff262a5cca1a1e1/ty-0.0.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:177d160295e6a56bdf0b61f6120bc4502fff301d4d10855ba711c109aa7f37fb", size = 9670317, upload-time = "2025-12-24T21:28:43.096Z" }, + { url = "https://files.pythonhosted.org/packages/b6/94/fe7106fd5e2ac06b81fba7b785a6216774618edc3fda9e17f58efe3cede6/ty-0.0.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30518b95ab5cc83615794cca765a5fb86df39a0d9c3dadc0ab2d787ab7830008", size = 10096517, upload-time = "2025-12-24T21:28:23.667Z" }, + { url = "https://files.pythonhosted.org/packages/45/d9/db96ccfd663c96bdd4bb63db72899198c01445012f939477a5318a563f14/ty-0.0.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7867b3f75c2d9602cc6fb3b6d462580b707c2d112d4b27037142b0d01f8bfd03", size = 10996406, upload-time = "2025-12-24T21:28:39.134Z" }, + { url = "https://files.pythonhosted.org/packages/94/da/103915c08c3e6a14f95959614646fcdc9a240cd9a039fadbdcd086c819ee/ty-0.0.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:878d45858e209b7904753fbc5155f4cb75dadc20a26bbb77614bfef31580f9ae", size = 10712829, upload-time = "2025-12-24T21:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d9be417bc8e459e13e9698978579eec9868f91f4c5d6ef663249967fec8b/ty-0.0.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:651820b193901825afce40ae68f6a51cd64dbfa4b81a45db90061401261f25e4", size = 10486541, upload-time = "2025-12-24T21:28:45.17Z" }, + { url = "https://files.pythonhosted.org/packages/ad/09/d1858c66620d8ae566e021ad0d7168914b1568841f8fe9e439116ce6b440/ty-0.0.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f56a5a0c1c045863b1b70c358a392b3f73b8528c5c571d409f19dd465525e116", size = 10255312, upload-time = "2025-12-24T21:28:53.17Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0a/78f75089db491fd5fcc13d2845a0b2771b7f7d377450c64c6616e9c227bc/ty-0.0.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:748218fbc1f7b7f1b9d14e77d4f3d7fec72af794417e26b0185bdb94153afe1c", size = 9696201, upload-time = "2025-12-24T21:28:57.345Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/b26e94832fd563fef6f77a4487affc77a027b0e53106422c66aafb37fa01/ty-0.0.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1ff80f3985a52a7358b9069b4a8d223e92cf312544a934a062d6d3a4fb6876b3", size = 9688907, upload-time = "2025-12-24T21:28:59.485Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8f/cc48601fb92c964cf6c34277e0d947076146b7de47aa11b5dbae45e01ce7/ty-0.0.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a808910ce672ba4446699f4c021283208f58f988bcfc3bdbdfc6e005819d9ee0", size = 9829982, upload-time = "2025-12-24T21:28:34.429Z" }, + { url = "https://files.pythonhosted.org/packages/b5/af/7fa9c2bfa25865968bded637f7e71f1a712f4fbede88f487b6a9101ab936/ty-0.0.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2718fea5f314eda01703fb406ec89b1fc8710b3fc6a09bbd6f7a4f3502ddc889", size = 10361037, upload-time = "2025-12-24T21:28:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5b/1a6ff1495975cd1c02aa8d03bc5c9d8006eaeb8bf354446f88d70f0518fd/ty-0.0.7-py3-none-win32.whl", hash = "sha256:ae89bb8dc50deb66f34eab3113aa61ac5d7f85ecf16279e5918548085a89021c", size = 9295092, upload-time = "2025-12-24T21:28:51.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f6/47e9364635d048002354f84d2d0d6dfc9eb166dc67850739f88e1fec4fc5/ty-0.0.7-py3-none-win_amd64.whl", hash = "sha256:25bd20e3d4d0f07b422f9b42711ba24d28116031273bd23dbda66cec14df1c06", size = 10162816, upload-time = "2025-12-24T21:28:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/c4fc28410c4493982b7481fb23f62bacb02fd2912ebec3b9bc7de18bebb8/ty-0.0.7-py3-none-win_arm64.whl", hash = "sha256:c87d27484dba9fca0053b6a9eee47eecc760aab2bbb8e6eab3d7f81531d1ad0c", size = 9653112, upload-time = "2025-12-24T21:28:31.562Z" }, ] [[package]] name = "uvtask" +version = "0.0.0" source = { editable = "." } [package.dev-dependencies] @@ -354,7 +355,7 @@ dev = [ { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.14.10" }, - { name = "ty", specifier = ">=0.0.4" }, + { name = "ty", specifier = ">=0.0.6" }, ] [[package]] diff --git a/uvtask/cli.py b/uvtask/cli.py index bd1c4fc..fddbce3 100644 --- a/uvtask/cli.py +++ b/uvtask/cli.py @@ -1,39 +1,115 @@ from pathlib import Path -from subprocess import run -from sys import argv, exit -from tomllib import loads +from sys import exit, stderr +from uvtask.colors import color_service +from uvtask.commands import ( + CommandBuilder, + CommandExecutorOrchestrator, + CommandValidator, + HelpCommandHandler, +) +from uvtask.config import ScriptLoader, VersionLoader, script_loader, version_loader +from uvtask.executor import command_executor +from uvtask.formatters import CustomArgumentParser +from uvtask.hooks import argv_hook_flag_parser, hook_discoverer +from uvtask.parser import ArgumentParserBuilder, ArgvParser -def main() -> None: - pyproject_path = Path("pyproject.toml") - if not pyproject_path.exists(): - print("Error: pyproject.toml not found in current directory!") - exit(1) +class CliApplication: + def __init__( + self, + script_loader: ScriptLoader, + version_loader: VersionLoader, + parser_builder: ArgumentParserBuilder, + argv_parser: ArgvParser, + command_validator: CommandValidator, + command_builder: CommandBuilder, + help_handler: HelpCommandHandler, + executor_orchestrator: CommandExecutorOrchestrator, + ): + self._script_loader = script_loader + self._version_loader = version_loader + self._parser_builder = parser_builder + self._argv_parser = argv_parser + self._command_validator = command_validator + self._command_builder = command_builder + self._help_handler = help_handler + self._executor_orchestrator = executor_orchestrator + + def run(self) -> None: + self._validate_pyproject_exists() + scripts, script_descriptions = self._script_loader.load_scripts_with_descriptions() + self._validate_reserved_commands(scripts) + + parser = self._parser_builder.build_main_parser() + self._parser_builder.add_subparsers(parser, scripts, script_descriptions) + + command_name, script_args, quiet_count, verbose_count = self._argv_parser.parse_global_options(scripts) + + if not command_name: + command_name = self._get_command_from_argparse(parser) + + if command_name == "help": + self._help_handler.handle_help(script_args[0] if script_args else None, scripts, script_descriptions, parser) - with open(pyproject_path) as file: - scripts = loads(file.read()).get("tool", {}).get("run-script", {}) + self._command_validator.validate_exists(command_name, scripts) - args = argv[1:] + script = scripts.get(command_name) + if script is None: + # This should never happen after validate_exists, but type checker needs this + self._command_validator.validate_exists(command_name, scripts) + return - if len(args) == 0: - args.append("-h") + no_hooks = argv_hook_flag_parser.parse_no_hooks() + pre_hooks, post_hooks = hook_discoverer.discover(command_name, scripts) if not no_hooks else ([], []) - if args[0] == "-h" or args[0] == "-help" or args[0] == "--help": - commands = (chr(10) + " ").join(scripts.keys()) - print("Usage: uvtask [COMMAND]\n\nCommands:\n {0}\n\nOptions:\n -h,--help".format(commands)) - exit(0) + main_commands = self._command_builder.build_commands(script, script_args, scripts) - script = scripts.get(args[0]) - if not script: - print(f"Error: Unknown command '{args[0]}'!") - print("Run 'uvtask --help' to see available commands.") - exit(1) + self._executor_orchestrator.execute(command_name, main_commands, pre_hooks, post_hooks, quiet_count, verbose_count) - script_args = " ".join(args[1:]) if len(args) > 1 else "" - command = f"{script} {script_args}".strip() + @staticmethod + def _validate_pyproject_exists() -> None: + pyproject_path = Path("pyproject.toml") + if not pyproject_path.exists(): + print("Error: pyproject.toml not found in current directory!", file=stderr) + exit(1) - try: - exit(run(command, check=False, shell=True).returncode) - except KeyboardInterrupt: - exit(130) + @staticmethod + def _validate_reserved_commands(scripts: dict[str, str | list[str]]) -> None: + if "help" in scripts: + error_text = color_service.bold_red("error") + print( + f"{error_text}: '{color_service.yellow('help')}' is a reserved command and cannot be used as a script name", + file=stderr, + ) + exit(1) + + @staticmethod + def _get_command_from_argparse(parser: CustomArgumentParser) -> str: + try: + args = parser.parse_args() + if not hasattr(args, "command") or not args.command: + parser.print_help() + exit(1) + return args.command + except SystemExit: + raise + except: # noqa: E722 + parser.print_help() + exit(1) + + +app = CliApplication( + script_loader, + version_loader, + ArgumentParserBuilder(version_loader), + ArgvParser(), + CommandValidator(), + CommandBuilder(), + HelpCommandHandler(), + CommandExecutorOrchestrator(command_executor), +) + + +def main() -> None: + app.run() diff --git a/uvtask/colors.py b/uvtask/colors.py new file mode 100644 index 0000000..a82a7a2 --- /dev/null +++ b/uvtask/colors.py @@ -0,0 +1,211 @@ +from enum import StrEnum +from os import getenv +from sys import argv, stderr +from typing import ClassVar, Protocol + + +class ColorPreference(StrEnum): + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + @classmethod + def from_string(cls, value: str) -> "ColorPreference": + try: + return cls(value.lower()) + except ValueError: + return cls.AUTO + + +class ColorFormatter(Protocol): + def format(self, text: str, style: str) -> str: ... + + +class ColorSupportChecker(Protocol): + def supports_color(self) -> bool: ... + + +class ColorPreferenceSource(Protocol): + def get_preference(self) -> ColorPreference: ... + + +class EnvironmentColorSupportChecker: + def supports_color(self) -> bool: + no_color = getenv("NO_COLOR") + if no_color is not None and no_color.lower().strip() not in ("0", "false"): + return False + + force_color = getenv("FORCE_COLOR") + if force_color is not None and force_color.lower().strip() not in ("0", "false"): + return True + + if getenv("TERM", "").lower() == "dumb": + return False + + return stderr.isatty() + + +class ColorSupportService: + def __init__( + self, + preference: ColorPreference | None, + environment_checker: ColorSupportChecker | None = None, + ): + self._preference = preference + self._environment_checker = environment_checker or EnvironmentColorSupportChecker() + + def supports_color(self) -> bool: + if self._preference == ColorPreference.ALWAYS: + return True + if self._preference == ColorPreference.NEVER: + return False + return self._environment_checker.supports_color() + + +class ArgvColorPreferenceSource: + def get_preference(self) -> ColorPreference | None: + for i, arg in enumerate(argv): + if arg == "--color": + if i + 1 < len(argv): + value = argv[i + 1] + return ColorPreference.from_string(value) + elif arg.startswith("--color="): + value = arg.split("=", 1)[1] + return ColorPreference.from_string(value) + return None + + +class EnvironmentColorPreferenceSource: + def get_preference(self) -> ColorPreference | None: + if getenv("NO_COLOR") is not None: + return ColorPreference.NEVER + if getenv("FORCE_COLOR") is not None: + return ColorPreference.ALWAYS + return None + + +class ColorPreferenceParser: + def __init__( + self, + sources: list[ColorPreferenceSource] | None = None, + ): + self._sources = sources or [ + ArgvColorPreferenceSource(), + EnvironmentColorPreferenceSource(), + ] + + def parse(self) -> ColorPreference: + for source in self._sources: + preference = source.get_preference() + if preference is not None: + return preference + return ColorPreference.AUTO + + +class AnsiColorFormatter: + # ANSI escape codes + RESET = "\x1b[0m" + BOLD = "\x1b[1m" + RED = "\x1b[31m" + GREEN = "\x1b[32m" + YELLOW = "\x1b[33m" + CYAN = "\x1b[36m" + + STYLES: ClassVar[dict[str, str]] = { + "bold_red": f"{BOLD}{RED}", + "bold_green": f"{BOLD}{GREEN}", + "bold": BOLD, + "bold_teal": f"{BOLD}{CYAN}", + "teal": CYAN, + "yellow": YELLOW, + "green": GREEN, + } + + def format(self, text: str, style: str) -> str: + style_code = self.STYLES.get(style, "") + if style_code: + return f"{style_code}{text}{self.RESET}" + return text + + +class NoOpColorFormatter: + def format(self, text: str, style: str) -> str: + return text + + +class ColorPreferenceManager: + def __init__(self, parser: ColorPreferenceParser): + self._preference: ColorPreference | None = None + self._parser = parser + self._support_service: ColorSupportService | None = None + + def set_preference(self, preference: ColorPreference) -> None: + self._preference = preference + self._support_service = None # Reset cached service + + def set_preference_from_string(self, preference: str) -> None: + self.set_preference(ColorPreference.from_string(preference)) + + def get_preference(self) -> ColorPreference: + if self._preference is None: + self._preference = self._parser.parse() + return self._preference + + def get_color_preference(self) -> str: + return self.get_preference().value + + def supports_color(self) -> bool: + if self._support_service is None: + self._support_service = ColorSupportService(self.get_preference()) + return self._support_service.supports_color() + + def parse_color_from_argv(self) -> str: + parser = ColorPreferenceParser() + parsed_pref = parser.parse() + self.set_preference(parsed_pref) + return parsed_pref.value + + +class ColorService: + def __init__( + self, + preference_manager: ColorPreferenceManager, + ): + self._preference_manager = preference_manager + self._formatter: ColorFormatter | None = None + + def _get_formatter(self) -> ColorFormatter: + if self._formatter is None: + if self._preference_manager.supports_color(): + self._formatter = AnsiColorFormatter() + else: + self._formatter = NoOpColorFormatter() + return self._formatter + + def format(self, text: str, style: str) -> str: + return self._get_formatter().format(text, style) + + def bold_red(self, text: str) -> str: + return self.format(text, "bold_red") + + def bold_green(self, text: str) -> str: + return self.format(text, "bold_green") + + def bold(self, text: str) -> str: + return self.format(text, "bold") + + def bold_teal(self, text: str) -> str: + return self.format(text, "bold_teal") + + def teal(self, text: str) -> str: + return self.format(text, "teal") + + def yellow(self, text: str) -> str: + return self.format(text, "yellow") + + def green(self, text: str) -> str: + return self.format(text, "green") + + +preference_manager = ColorPreferenceManager(ColorPreferenceParser()) +color_service = ColorService(preference_manager) diff --git a/uvtask/commands.py b/uvtask/commands.py new file mode 100644 index 0000000..e1de628 --- /dev/null +++ b/uvtask/commands.py @@ -0,0 +1,269 @@ +from sys import exit, stderr + +from uvtask.colors import color_service, preference_manager +from uvtask.executor import CommandExecutor +from uvtask.formatters import CommandMatcher, CustomArgumentParser + + +class CommandValidator: + def __init__(self, command_matcher: CommandMatcher | None = None): + self._matcher = command_matcher or CommandMatcher() + + def validate_exists(self, command_name: str, scripts: dict[str, str | list[str]]) -> None: + if command_name not in scripts: + error_text = color_service.bold_red("error") + usage_text = color_service.bold_green("Usage:") + prog_text = color_service.bold_teal("uvtask") + options_text = color_service.teal("[OPTIONS]") + command_text = color_service.teal("") + help_text = color_service.bold_teal("--help") + + print(f"{error_text}: unrecognized subcommand '{color_service.yellow(command_name)}'", file=stderr) + + available_commands = list(scripts.keys()) + similar = self._matcher.find_similar(command_name, available_commands) + if similar: + tip_text = color_service.yellow("tip") + similar_cmd = color_service.yellow(f"'{similar}'") + print(f"\n {tip_text}: a similar subcommand exists: {similar_cmd}", file=stderr) + + print(f"\n{usage_text} {prog_text} {options_text} {command_text}", file=stderr) + print(f"\nFor more information, try '{help_text}'.", file=stderr) + exit(1) + + +class CommandResolver: + @staticmethod + def resolve_command_references(command: str, all_scripts: dict[str, str | list[str]], visited: set[str] | None = None) -> list[str]: + if visited is None: + visited = set() + + if command in visited: + raise ValueError(f"Circular reference detected: {' -> '.join(visited)} -> {command}") + + if command not in all_scripts: + return [command] + + visited.add(command) + referenced_script = all_scripts[command] + + if isinstance(referenced_script, str): + result = CommandResolver.resolve_command_references(referenced_script, all_scripts, visited.copy()) + elif isinstance(referenced_script, list): + result = [] + for cmd in referenced_script: + if cmd in all_scripts: + result.extend(CommandResolver.resolve_command_references(cmd, all_scripts, visited.copy())) + else: + result.append(cmd) + else: + result = [str(referenced_script)] + + visited.remove(command) + return result + + @staticmethod + def resolve_list_references(commands: list[str], all_scripts: dict[str, str | list[str]]) -> list[str]: + resolved = [] + for cmd in commands: + if cmd in all_scripts: + resolved.extend(CommandResolver.resolve_command_references(cmd, all_scripts)) + else: + resolved.append(cmd) + return resolved + + +class CommandBuilder: + def __init__(self, resolver: CommandResolver | None = None): + self._resolver = resolver or CommandResolver() + + def build_commands(self, script: str | list[str], script_args: list[str], all_scripts: dict[str, str | list[str]] | None = None) -> list[str]: + script_args_str = " ".join(script_args) if script_args else "" + + if isinstance(script, str): + if all_scripts and script in all_scripts: + resolved = self._resolver.resolve_command_references(script, all_scripts) + return [f"{cmd} {script_args_str}".strip() for cmd in resolved] + return [f"{script} {script_args_str}".strip()] + elif isinstance(script, list): + if all_scripts: + resolved = self._resolver.resolve_list_references(script, all_scripts) + return [f"{cmd} {script_args_str}".strip() for cmd in resolved] + return [f"{cmd} {script_args_str}".strip() for cmd in script] + else: + raise ValueError(f"Invalid script format: {script}") + + +class HelpCommandHandler: + def __init__(self, command_matcher: CommandMatcher | None = None): + self._matcher = command_matcher or CommandMatcher() + + def handle_help( + self, + help_command_name: str | None, + scripts: dict[str, str | list[str]], + script_descriptions: dict[str, str], + parser: "CustomArgumentParser", # type: ignore + ) -> None: + if help_command_name: + self._show_command_help(help_command_name, scripts, script_descriptions) + else: + self._show_general_help(parser) + + def _show_command_help(self, command_name: str, scripts: dict[str, str | list[str]], script_descriptions: dict[str, str]) -> None: + if command_name not in scripts: + error_text = color_service.bold_red("error") + print(f"{error_text}: unknown command '{color_service.yellow(command_name)}'", file=stderr) + + available_commands = list(scripts.keys()) + similar = self._matcher.find_similar(command_name, available_commands) + if similar: + tip_text = color_service.yellow("tip") + similar_cmd = color_service.yellow(f"'{similar}'") + print(f"\n {tip_text}: a similar command exists: {similar_cmd}", file=stderr) + + exit(1) + + description = script_descriptions.get(command_name, "") + command_cmd = color_service.bold_teal(command_name) if preference_manager.supports_color() else command_name + + if description: + print(description) + else: + no_desc_text = color_service.yellow("No description provided") if preference_manager.supports_color() else "No description provided" + print(no_desc_text) + print() + print("To add a description, update your pyproject.toml:") + print() + + actual_command = scripts.get(command_name, "...") + # Handle both str and list[str] cases + if isinstance(actual_command, list): + actual_command = actual_command[0] if actual_command else "..." + escaped_command = actual_command.replace('"', '\\"').replace('\n', '\\n') + if len(actual_command) > 50 or '\n' in actual_command: + example_cmd = ( + color_service.bold_teal(f' {command_name} = {{ command = "...", description = "Your description here" }}') + if preference_manager.supports_color() + else f' {command_name} = {{ command = "...", description = "Your description here" }}' + ) + else: + example_cmd = ( + color_service.bold_teal(f' {command_name} = {{ command = "{escaped_command}", description = "Your description here" }}') + if preference_manager.supports_color() + else f' {command_name} = {{ command = "{escaped_command}", description = "Your description here" }}' + ) + print(example_cmd) + + print() + usage_text = color_service.bold_green("Usage:") if preference_manager.supports_color() else "Usage:" + prog_text = color_service.bold_teal("uvtask") if preference_manager.supports_color() else "uvtask" + options_text = color_service.teal("[OPTIONS]") if preference_manager.supports_color() else "[OPTIONS]" + command_arg_text = color_service.teal("[COMMAND]") if preference_manager.supports_color() else "[COMMAND]" + print(f"{usage_text} {prog_text} {command_cmd} {options_text} {command_arg_text}") + print() + help_cmd_text = color_service.bold("uvtask help ") if preference_manager.supports_color() else "uvtask help " + print(f"Use `{help_cmd_text}` for more information on a specific command.") + print() + exit(0) + + def _show_general_help(self, parser: "CustomArgumentParser") -> None: + original_epilog = parser.epilog + help_cmd_text = color_service.bold("uvtask help ") + parser.epilog = f"\n\nUse `{help_cmd_text}` for more information on a specific command." + parser.print_help() + print() + parser.epilog = original_epilog + exit(0) + + +class CommandExecutorOrchestrator: + def __init__( + self, + executor: CommandExecutor, + verbose_output: "VerboseOutputHandler" | None = None, + ): + self._executor = executor + self._verbose = verbose_output or VerboseOutputHandler() + + def execute( + self, + command_name: str, + main_commands: list[str], + pre_hooks: list[str], + post_hooks: list[str], + quiet_count: int, + verbose_count: int, + ) -> None: + try: + self._verbose.show_execution_info(command_name, main_commands, pre_hooks, post_hooks, verbose_count) + + for hook_command in pre_hooks: + exit_code = self._executor.execute(hook_command, quiet_count, verbose_count) + if exit_code != 0: + self._verbose.show_hook_failure("Pre-hook", exit_code, verbose_count) + exit(exit_code) + + main_exit_code = 0 + for i, main_command in enumerate(main_commands): + if verbose_count > 0 and len(main_commands) > 1: + print( + color_service.bold_green(f"Executing command {i + 1}/{len(main_commands)}"), + file=stderr, + ) + exit_code = self._executor.execute(main_command, quiet_count, verbose_count) + if exit_code != 0: + main_exit_code = exit_code + self._verbose.show_command_failure(exit_code, verbose_count) + break + + for hook_command in post_hooks: + hook_exit_code = self._executor.execute(hook_command, quiet_count, verbose_count) + if main_exit_code == 0 and hook_exit_code != 0: + main_exit_code = hook_exit_code + self._verbose.show_hook_failure("Post-hook", hook_exit_code, verbose_count) + + self._verbose.show_final_exit_code(main_exit_code, verbose_count) + exit(main_exit_code) + except KeyboardInterrupt: + if verbose_count > 0: + print(color_service.bold_red("Interrupted by user"), file=stderr) + exit(130) + + +class VerboseOutputHandler: + @staticmethod + def show_execution_info( + command_name: str, + main_commands: list[str], + pre_hooks: list[str], + post_hooks: list[str], + verbose_count: int, + ) -> None: + if verbose_count > 0: + print(color_service.bold_green(f"Command: {command_name}"), file=stderr) + if pre_hooks: + print(color_service.bold_green(f"Pre-hooks: {len(pre_hooks)}"), file=stderr) + if len(main_commands) > 1: + print(color_service.bold_green(f"Commands to execute: {len(main_commands)}"), file=stderr) + if post_hooks: + print(color_service.bold_green(f"Post-hooks: {len(post_hooks)}"), file=stderr) + print("", file=stderr) + + @staticmethod + def show_hook_failure(hook_type: str, exit_code: int, verbose_count: int) -> None: + if verbose_count > 0: + print(color_service.bold_red(f"{hook_type} failed with exit code {exit_code}"), file=stderr) + + @staticmethod + def show_command_failure(exit_code: int, verbose_count: int) -> None: + if verbose_count > 0: + print(color_service.bold_red(f"Command failed with exit code {exit_code}"), file=stderr) + + @staticmethod + def show_final_exit_code(exit_code: int, verbose_count: int) -> None: + if verbose_count > 0: + final_exit_text = ( + color_service.bold_green(f"Final exit code: {exit_code}") if exit_code == 0 else color_service.bold_red(f"Final exit code: {exit_code}") + ) + print(final_exit_text, file=stderr) diff --git a/uvtask/config.py b/uvtask/config.py new file mode 100644 index 0000000..064ed0a --- /dev/null +++ b/uvtask/config.py @@ -0,0 +1,111 @@ +from pathlib import Path +from tomllib import loads + + +class PyProjectReader: + def __init__(self, path: Path): + self._path = path + + def exists(self) -> bool: + return self._path.exists() + + def read(self) -> dict: + if not self.exists(): + return {} + with open(self._path) as file: + return loads(file.read()) + + +class ScriptValueParser: + @staticmethod + def parse(script_name: str, script_value: str | list[str] | dict) -> tuple[str | list[str], str]: + if isinstance(script_value, str): + return script_value, "" + elif isinstance(script_value, list): + return script_value, "" + elif isinstance(script_value, dict): + if "command" not in script_value: + return str(script_value), "" + cmd_value = script_value["command"] + description = script_value.get("description", "") + if isinstance(cmd_value, list): + return cmd_value, description + return cmd_value, description + raise ValueError(f"Invalid script value: {script_value}") + + +class RunScriptSectionReader: + @staticmethod + def get_run_script_section(tool_section: dict) -> dict: + if "uvtask" in tool_section and "run-script" in tool_section["uvtask"]: + return tool_section["uvtask"]["run-script"] + return tool_section.get("run-script", {}) + + +class ScriptLoader: + def __init__( + self, + reader: PyProjectReader, + script_parser: ScriptValueParser, + section_reader: RunScriptSectionReader, + ): + self._reader = reader + self._parser = script_parser + self._section_reader = section_reader + + def load_scripts(self) -> dict[str, str]: + if not self._reader.exists(): + return {} + data = self._reader.read() + tool_section = data.get("tool", {}) + run_script = self._section_reader.get_run_script_section(tool_section) + # Convert all to strings for backward compatibility + scripts = {} + for script_name, script_value in run_script.items(): + command, _ = self._parser.parse(script_name, script_value) + if isinstance(command, list): + scripts[script_name] = str(command[0]) if command else "" + else: + scripts[script_name] = command + return scripts + + def load_scripts_with_descriptions( + self, + ) -> tuple[dict[str, str | list[str]], dict[str, str]]: + if not self._reader.exists(): + return {}, {} + + data = self._reader.read() + tool_section = data.get("tool", {}) + run_script = self._section_reader.get_run_script_section(tool_section) + + scripts: dict[str, str | list[str]] = {} + descriptions: dict[str, str] = {} + + for script_name, script_value in run_script.items(): + command, description = self._parser.parse(script_name, script_value) + scripts[script_name] = command + descriptions[script_name] = description + + return scripts, descriptions + + +class VersionLoader: + def __init__(self, reader: PyProjectReader): + self._reader = reader + + def get_version(self) -> str: + if not self._reader.exists(): + return "unknown" + data = self._reader.read() + return data.get("project", {}).get("version", "unknown") + + +pyproject_reader = PyProjectReader(Path("pyproject.toml")) + +script_loader = ScriptLoader( + reader=pyproject_reader, + script_parser=ScriptValueParser(), + section_reader=RunScriptSectionReader(), +) +version_loader = VersionLoader(pyproject_reader) diff --git a/uvtask/executor.py b/uvtask/executor.py new file mode 100644 index 0000000..3b71624 --- /dev/null +++ b/uvtask/executor.py @@ -0,0 +1,52 @@ +import subprocess +from subprocess import run +from sys import stderr + +from uvtask.colors import color_service, preference_manager + + +class CommandExecutor: + def execute( + self, + command: str, + quiet_count: int = 0, + verbose_count: int = 0, + ) -> int: + try: + if verbose_count > 0: + cmd_text = color_service.bold_teal(f"Running: {command}") if preference_manager.supports_color() else f"Running: {command}" + print(cmd_text, file=stderr) + + if quiet_count > 0: + result = subprocess.run( + command, + shell=True, + check=False, + stdout=subprocess.DEVNULL if quiet_count >= 1 else None, + stderr=subprocess.DEVNULL if quiet_count >= 2 else None, + ) + if verbose_count > 0: + exit_text = ( + color_service.bold_green(f"Exit code: {result.returncode}") + if result.returncode == 0 + else color_service.bold_red(f"Exit code: {result.returncode}") + ) + print(exit_text, file=stderr) + return result.returncode + else: + result = run(command, check=False, shell=True) + if verbose_count > 0: + exit_text = ( + color_service.bold_green(f"Exit code: {result.returncode}") + if result.returncode == 0 + else color_service.bold_red(f"Exit code: {result.returncode}") + ) + print(exit_text, file=stderr) + return result.returncode + except KeyboardInterrupt: + if verbose_count > 0: + print(color_service.bold_red("Interrupted by user"), file=stderr) + return 130 + + +command_executor = CommandExecutor() diff --git a/uvtask/formatters.py b/uvtask/formatters.py new file mode 100644 index 0000000..4faae16 --- /dev/null +++ b/uvtask/formatters.py @@ -0,0 +1,411 @@ +import argparse +import re +from collections.abc import Iterable +from sys import exit, stderr +from typing import ClassVar, NoReturn + +from uvtask.colors import color_service, preference_manager + + +class CommandMatcher: + def find_similar(self, command: str, available_commands: list[str]) -> str | None: + if not available_commands: + return None + + def levenshtein_distance(s1: str, s2: str) -> int: + if len(s1) < len(s2): + return levenshtein_distance(s2, s1) + if len(s2) == 0: + return len(s1) + previous_row = list(range(len(s2) + 1)) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = previous_row[j + 1] + 1 + deletions = current_row[j] + 1 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + return previous_row[-1] + + def similarity(cmd1: str, cmd2: str) -> float: + cmd1_lower = cmd1.lower() + cmd2_lower = cmd2.lower() + if cmd1_lower == cmd2_lower: + return 1.0 + if cmd1_lower.startswith(cmd2_lower) or cmd2_lower.startswith(cmd1_lower): + return 0.7 + if cmd1_lower in cmd2_lower or cmd2_lower in cmd1_lower: + return 0.6 + max_len = max(len(cmd1), len(cmd2)) + if max_len == 0: + return 0.0 + distance = levenshtein_distance(cmd1_lower, cmd2_lower) + return 1.0 - (distance / max_len) + + best_match = None + best_score = 0.0 + for available_cmd in available_commands: + score = similarity(command, available_cmd) + if score > best_score and score > 0.4: + best_score = score + best_match = available_cmd + return best_match + + +class AnsiStripper: + _ansi_escape = re.compile(r'\x1b\[[0-9;]*m') + + @classmethod + def strip(cls, text: str) -> str: + return cls._ansi_escape.sub('', text) + + +class OptionSorter: + _ORDER_MAP: ClassVar[dict[str, int]] = { + "-q": 0, + "--quiet": 0, + "-v": 1, + "--verbose": 1, + "--color": 2, + "--no-hooks": 3, + "--ignore-scripts": 3, + "-V": 4, + "--version": 4, + "-h": 5, + "--help": 5, + } + + @classmethod + def sort(cls, lines: list[str]) -> list[str]: + options = [] + current_option = [] + + for line in lines: + stripped = line.strip() + is_option_line = stripped and (stripped.startswith("-") or (line.startswith(" ") and not line.startswith(" ") and stripped)) + + if is_option_line: + if current_option: + options.append(current_option) + current_option = [line] + elif current_option: + current_option.append(line) + else: + if current_option: + options.append(current_option) + current_option = [] + if stripped: + options.append([line]) + + if current_option: + options.append(current_option) + + def get_order(option_lines: list[str]) -> int: + for line in option_lines: + sorted_opts = sorted(cls._ORDER_MAP.items(), key=lambda x: len(x[0]), reverse=True) + for opt, order in sorted_opts: + if opt in line: + return order + return 999 + + options.sort(key=get_order) + + result = [] + for option in options: + for line in option: + if line.strip(): + result.append(line) + return result + + +class HelpTextProcessor: + def __init__(self, ansi_stripper: AnsiStripper): + self._strip = ansi_stripper.strip + + def process_help_text(self, help_text: str) -> str: + lines = help_text.split("\n") + result = self._process_sections(lines) + return self._add_section_spacing(result) + + def _process_sections(self, lines: list[str]) -> list[str]: + result = [] + in_global_options = False + global_options_lines = [] + description_done = False + usage_added = False + + for i, line in enumerate(lines): + stripped = self._strip(line) + + # Handle color stripping for section headers + processed_line = line + if not preference_manager.supports_color(): + if "Global options" in stripped: + processed_line = "Global options:" + elif "Commands" in stripped and ":" in stripped: + processed_line = "Commands:" + + # Process Global options section + if "Global options" in stripped: + in_global_options = True + if result and result[-1].strip(): + result.append("") + result.append(processed_line) + elif in_global_options and line.strip() and not line.startswith(" ") and not line.startswith(" "): + in_global_options = False + sorted_lines = OptionSorter.sort(global_options_lines) + result.extend(sorted_lines) + global_options_lines = [] + if "Use `" in stripped or "for more information" in stripped.lower(): + result.append("") + result.append(line) + elif in_global_options: + global_options_lines.append(line) + # Handle description and Usage line + elif not description_done and line.strip() and "Commands" not in self._strip(line): + result.append(line) + if i + 1 < len(lines): + next_line = lines[i + 1] + stripped_next = self._strip(next_line) + if "Commands" in stripped_next or (not next_line.strip() and i + 2 < len(lines) and "Commands" in self._strip(lines[i + 2])): + description_done = True + if result and result[-1].strip(): + result.append("") + elif "Commands" in self._strip(line) and not usage_added: + self._add_usage_line(result) + usage_added = True + result.append(processed_line) + else: + result.append(line) + + if global_options_lines: + sorted_lines = OptionSorter.sort(global_options_lines) + result.extend(sorted_lines) + + return result + + def _add_usage_line(self, result: list[str]) -> None: + usage_text = color_service.bold_green("Usage:") if preference_manager.supports_color() else "Usage:" + prog_text = color_service.bold_teal("uvtask") if preference_manager.supports_color() else "uvtask" + options_text = color_service.teal("[OPTIONS]") if preference_manager.supports_color() else "[OPTIONS]" + command_text = color_service.teal("") if preference_manager.supports_color() else "" + result.append(f"{usage_text} {prog_text} {options_text} {command_text}") + result.append("") + + def _add_section_spacing(self, lines: list[str]) -> str: + new_lines = [] + for i, line in enumerate(lines): + stripped = self._strip(line) + is_usage = stripped.startswith("Usage:") + is_commands = stripped == "Commands:" + is_global_options = stripped == "Global options:" + is_epilog = "Use `" in stripped and ( + "uvtask help" in stripped or "for more details" in stripped.lower() or "for more information" in stripped.lower() + ) + + is_section_header = is_usage or is_commands or is_global_options + + if is_section_header and i > 0: + prev_line = lines[i - 1] if i > 0 else "" + if prev_line.strip() and (not new_lines or new_lines[-1].strip()): + new_lines.append("") + + if is_epilog: + if i > 0 and lines[i - 1].strip() and (not new_lines or new_lines[-1].strip()): + new_lines.append("") + new_lines.append(line) + elif not line.strip(): + if i + 1 < len(lines): + next_stripped = self._strip(lines[i + 1]) + next_is_epilog = "Use `" in next_stripped and ( + "uvtask help" in next_stripped or "for more details" in next_stripped.lower() or "for more information" in next_stripped.lower() + ) + next_is_section = next_stripped.startswith("Usage:") or next_stripped in {"Commands:", "Global options:"} + if next_is_epilog or next_is_section: + new_lines.append(line) + else: + new_lines.append(line) + + return "\n".join(new_lines) + + +class CustomHelpFormatter(argparse.RawDescriptionHelpFormatter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ansi_stripper = AnsiStripper() + self._help_processor = HelpTextProcessor(self._ansi_stripper) + + def _format_action_invocation(self, action: argparse.Action) -> str: + if not action.option_strings: + default = self._get_default_metavar_for_positional(action) + return self._metavar_formatter(action, default)(1)[0] + + parts = [] + for option_string in action.option_strings: + formatted = color_service.bold_teal(option_string) if preference_manager.supports_color() else option_string + parts.append(formatted) + + result = ", ".join(parts) + + # Add "..." for count actions + is_count_action = isinstance(action, argparse._CountAction) + if is_count_action and any(opt in ["-q", "--quiet", "-v", "--verbose"] for opt in action.option_strings): + result += "..." + + # Add metavar + metavar_str = self._get_metavar_str(action) + if metavar_str: + if "--color" in action.option_strings: + formatted_metavar = f"<{color_service.teal(metavar_str) if preference_manager.supports_color() else metavar_str}>" + result = f"{result} {formatted_metavar}" + else: + result = f"{result} {metavar_str}" + + return result + + def _get_metavar_str(self, action: argparse.Action) -> str: + if action.metavar is not None: + # metavar can be str or tuple[str, ...], convert tuple to str + if isinstance(action.metavar, tuple): + return action.metavar[0] if action.metavar else "" + return action.metavar + if action.choices is not None: + if "--color" in action.option_strings: + return "COLOR_CHOICE" + return self._format_args(action, self._get_default_metavar_for_optional(action)) + return self._format_args(action, self._get_default_metavar_for_optional(action)) + + def _format_action(self, action: argparse.Action) -> str: + if isinstance(action, argparse._SubParsersAction): + return self._format_subparsers(action) + + help_text = self._get_help_text(action) + invocation = self._format_action_invocation(action) + clean_invocation = self._ansi_stripper.strip(invocation) + width = 30 + + if len(clean_invocation) < width: + aligned_invocation = invocation + " " * (width - len(clean_invocation)) + return f" {aligned_invocation}{help_text}\n" if help_text else f" {invocation}\n" + else: + if help_text: + return f" {invocation}\n {help_text}\n" + return f" {invocation}\n" + + def _format_subparsers(self, action: argparse._SubParsersAction) -> str: + max_cmd_width = 24 + for choice in action.choices.keys(): + clean_choice = self._ansi_stripper.strip(choice) + max_cmd_width = max(max_cmd_width, len(clean_choice)) + width = max_cmd_width + 2 + + choices_help = {} + if hasattr(action, '_choices_actions'): + for choice_action in action._choices_actions: + if choice_action.help: + choices_help[choice_action.dest] = choice_action.help + + parts = [] + for choice, _ in action.choices.items(): + help_text = choices_help.get(choice, "") + cmd_name = color_service.bold_teal(choice) if preference_manager.supports_color() else choice + clean_cmd = self._ansi_stripper.strip(cmd_name) + + if len(clean_cmd) < width and help_text: + aligned_cmd = cmd_name + " " * (width - len(clean_cmd)) + parts.append(f" {aligned_cmd}{help_text}") + else: + parts.append(f" {cmd_name}") + if help_text: + parts.append(f" {help_text}") + + return "\n".join(parts) + "\n" if parts else "" + + def _get_help_text(self, action: argparse.Action) -> str: + help_text = self._expand_help(action) if action.help else "" + help_text = help_text.replace("%(default)s", str(action.default)) + help_text = help_text.replace("%(prog)s", self._prog) + + if action.option_strings: + if "--help" in action.option_strings or "-h" in action.option_strings: + return "Display the concise help for this command" + elif "--quiet" in action.option_strings or "-q" in action.option_strings: + return "Use quiet output" + elif "--verbose" in action.option_strings or "-v" in action.option_strings: + return "Use verbose output" + elif "--version" in action.option_strings or "-V" in action.option_strings: + return "Display the uvtask version" + + return help_text + + def _format_usage(self, usage: str | None, actions: Iterable[argparse.Action], groups: Iterable[argparse._ArgumentGroup], prefix: str | None) -> str: + if usage is None: + usage = "" + if prefix is None: + prefix = "Usage: " + usage_text = color_service.bold_green(prefix) if preference_manager.supports_color() else prefix + return f"{usage_text}{usage}" + + def add_usage(self, usage: str | None, actions: Iterable[argparse.Action], groups: Iterable[argparse._ArgumentGroup], prefix: str | None = None) -> None: + if usage is not None: + text = self._format_usage(usage, actions, groups, prefix) + self._add_item(self._format_text, [text]) + + def start_section(self, heading: str | None) -> None: + if heading is None: + heading = "" + if heading == "options": + heading = color_service.bold_green("Global options") if preference_manager.supports_color() else "Global options" + elif heading == "positional arguments": + heading = color_service.bold_green("Commands") if preference_manager.supports_color() else "Commands" + super().start_section(heading) + + def format_help(self) -> str: + help_text = super().format_help() + return self._help_processor.process_help_text(help_text) + + +class CustomArgumentParser(argparse.ArgumentParser): + def __init__(self, *args, **kwargs): + kwargs.setdefault("formatter_class", CustomHelpFormatter) + super().__init__(*args, **kwargs) + self._command_matcher = CommandMatcher() + + def error(self, message: str) -> NoReturn: + color_pref = preference_manager.parse_color_from_argv() + preference_manager.set_preference_from_string(color_pref) + + if "invalid choice:" in message: + match = re.search(r"invalid choice: '([^']+)'", message) + if match: + command = match.group(1) + error_text = color_service.bold_red("error") + usage_text = color_service.bold_green("Usage:") + prog_text = color_service.bold_teal(self.prog) + options_text = color_service.teal("[OPTIONS]") + command_text = color_service.teal("") + help_text = color_service.bold_teal("--help") + + available_commands = [] + if hasattr(self, '_subparsers') and self._subparsers is not None: + for action in self._subparsers._actions: + if isinstance(action, argparse._SubParsersAction): + available_commands = list(action.choices.keys()) + break + + print(f"{error_text}: unrecognized subcommand '{color_service.yellow(command)}'", file=stderr) + + similar = self._command_matcher.find_similar(command, available_commands) + if similar: + tip_text = color_service.green("tip") + similar_cmd = color_service.green(f"'{similar}'") + print(f"\n {tip_text}: a similar subcommand exists: {similar_cmd}", file=stderr) + + print(f"\n{usage_text} {prog_text} {options_text} {command_text}", file=stderr) + print(f"\nFor more information, try '{help_text}'.", file=stderr) + exit(1) + super().error(message) + + +command_matcher = CommandMatcher() diff --git a/uvtask/hooks.py b/uvtask/hooks.py new file mode 100644 index 0000000..dd53891 --- /dev/null +++ b/uvtask/hooks.py @@ -0,0 +1,110 @@ +from sys import argv, exit, stderr + +from uvtask.colors import color_service + + +class HookNameGenerator: + @staticmethod + def composer_names(command_name: str) -> tuple[str, str]: + return f"pre-{command_name}", f"post-{command_name}" + + @staticmethod + def npm_names(command_name: str) -> tuple[str, str]: + return f"pre{command_name}", f"post{command_name}" + + +class HookStyleValidator: + def __init__(self, name_generator: HookNameGenerator): + self._name_generator = name_generator + + def validate_consistency( + self, + command_name: str, + has_composer_pre: bool, + has_composer_post: bool, + has_npm_pre: bool, + has_npm_post: bool, + ) -> None: + uses_composer_style = has_composer_pre or has_composer_post + uses_npm_style = has_npm_pre or has_npm_post + + if uses_composer_style and uses_npm_style: + composer_pre, composer_post = self._name_generator.composer_names(command_name) + npm_pre, npm_post = self._name_generator.npm_names(command_name) + + error_text = color_service.bold_red("error") + composer_pre_name = color_service.yellow(composer_pre) + composer_post_name = color_service.yellow(composer_post) + npm_pre_name = color_service.yellow(npm_pre) + npm_post_name = color_service.yellow(npm_post) + + print( + f"{error_text}: inconsistent hook naming style for command '{color_service.yellow(command_name)}'", + file=stderr, + ) + print( + f" Use either Composer-style ({composer_pre_name}, {composer_post_name}) or NPM-style ({npm_pre_name}, {npm_post_name}), but not both.", + file=stderr, + ) + exit(1) + + +class HookCommandExtractor: + @staticmethod + def extract_commands(hook_value: str | list[str]) -> list[str]: + if isinstance(hook_value, list): + return hook_value + return [hook_value] + + +class HookDiscoverer: + def __init__( + self, + name_generator: HookNameGenerator, + validator: HookStyleValidator, + extractor: HookCommandExtractor, + ): + self._name_generator = name_generator + self._validator = validator + self._extractor = extractor + + def discover(self, command_name: str, all_scripts: dict[str, str | list[str]]) -> tuple[list[str], list[str]]: + composer_pre, composer_post = self._name_generator.composer_names(command_name) + npm_pre, npm_post = self._name_generator.npm_names(command_name) + + has_composer_pre = composer_pre in all_scripts + has_composer_post = composer_post in all_scripts + has_npm_pre = npm_pre in all_scripts + has_npm_post = npm_post in all_scripts + + self._validator.validate_consistency(command_name, has_composer_pre, has_composer_post, has_npm_pre, has_npm_post) + + pre_hooks = [] + post_hooks = [] + + if has_composer_pre: + pre_hooks.extend(self._extractor.extract_commands(all_scripts[composer_pre])) + elif has_npm_pre: + pre_hooks.extend(self._extractor.extract_commands(all_scripts[npm_pre])) + + if has_composer_post: + post_hooks.extend(self._extractor.extract_commands(all_scripts[composer_post])) + elif has_npm_post: + post_hooks.extend(self._extractor.extract_commands(all_scripts[npm_post])) + + return pre_hooks, post_hooks + + +class ArgvHookFlagParser: + @staticmethod + def parse_no_hooks() -> bool: + return "--no-hooks" in argv or "--ignore-scripts" in argv + + +name_generator = HookNameGenerator() +hook_discoverer = HookDiscoverer( + name_generator, + HookStyleValidator(name_generator), + HookCommandExtractor(), +) +argv_hook_flag_parser = ArgvHookFlagParser() diff --git a/uvtask/parser.py b/uvtask/parser.py new file mode 100644 index 0000000..8cf9399 --- /dev/null +++ b/uvtask/parser.py @@ -0,0 +1,121 @@ +from sys import argv + +from uvtask.colors import preference_manager +from uvtask.config import VersionLoader +from uvtask.formatters import CustomArgumentParser + + +class ArgvParser: + def __init__(self, argv_list: list[str] | None = None): + self._argv = argv_list if argv_list is not None else argv + + def parse_global_options(self, scripts: dict[str, str | list[str]]) -> tuple[str | None, list[str], int, int]: + script_args_list = [] + command_name = None + skip_next = False + quiet_count = 0 + verbose_count = 0 + + for i, arg in enumerate(self._argv[1:], 1): + if skip_next: + skip_next = False + # Process the color value (current arg is the value after --color) + preference_manager.set_preference_from_string(arg) + continue + if arg == "--color": + skip_next = True + continue + if arg.startswith("--color="): + color_val = arg.split("=", 1)[1] + preference_manager.set_preference_from_string(color_val) + continue + if arg in ["-q", "--quiet"]: + quiet_count += 1 + continue + if arg in ["-v", "--verbose"]: + verbose_count += 1 + continue + if arg in ["-V", "--version", "-h", "--help"]: + continue + if arg in ["--no-hooks", "--ignore-scripts"]: + continue + if arg in scripts or arg == "help": + command_name = arg + script_args_list = self._argv[i + 1 :] + break + + return command_name, script_args_list, quiet_count, verbose_count + + +class ArgumentParserBuilder: + def __init__(self, version_loader: VersionLoader): + self._version_loader = version_loader + + def build_main_parser(self) -> CustomArgumentParser: + parser = CustomArgumentParser( + prog="uvtask", + description="An extremely fast Python task runner.", + epilog="Use `uvtask help` for more details.", + ) + + parser.add_argument( + "-q", + "--quiet", + action="count", + default=0, + help="Use quiet output", + ) + parser.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Use verbose output", + ) + parser.add_argument( + "--no-hooks", + "--ignore-scripts", + dest="no_hooks", + action="store_true", + help="Skip running pre and post hooks/scripts", + ) + parser.add_argument( + "--color", + choices=["auto", "always", "never"], + default="auto", + metavar="COLOR_CHOICE", + help="Control the use of color in output [possible values: auto, always, never]", + ) + parser.add_argument( + "-V", + "--version", + action="version", + version=f"%(prog)s {self._version_loader.get_version()}", + ) + + return parser + + def add_subparsers(self, parser: CustomArgumentParser, scripts: dict[str, str | list[str]], script_descriptions: dict[str, str]) -> None: + subparsers = parser.add_subparsers(dest="command", metavar="COMMAND") + + for script_name, script_command in scripts.items(): + if self._is_hook(script_name, scripts): + continue + + description = script_descriptions.get(script_name, f"Run {script_name}") + help_text = script_descriptions.get(script_name, f"Run {script_name}") + subparsers.add_parser(script_name, help=help_text, description=description) + + help_parser = subparsers.add_parser("help", help="Display documentation for a command", description="Display documentation for a command") + help_parser.add_argument("command_name", nargs="?", help="The command to show help for") + + @staticmethod + def _is_hook(script_name: str, all_scripts: dict[str, str | list[str]]) -> bool: + for cmd_name in all_scripts.keys(): + if cmd_name == script_name: + continue + if script_name in (f"pre-{cmd_name}", f"post-{cmd_name}"): + return True + if script_name in (f"pre{cmd_name}", f"post{cmd_name}"): + return True + return False