diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bafa559..86f1502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,16 @@ jobs: run: uvx uvtask static-analysis:types if: ${{ env.PIPELINE_TESTS == 'true' }} + - name: complexity-visibility + shell: bash + run: uvx uvtask complexity:visibility + if: ${{ env.PIPELINE_TESTS == 'true' }} + + - name: complexity-enforcement + shell: bash + run: uvx uvtask complexity:enforcement + if: ${{ env.PIPELINE_TESTS == 'true' }} + - name: unit-tests shell: bash run: uvx uvtask unit-tests diff --git a/pyproject.toml b/pyproject.toml index dde41a7..29c4f5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,22 +49,35 @@ requires-python = ">=3.13" [dependency-groups] dev = [ - "bandit>=1.9.2", # security-analysis - "pip-licenses>=5.5.0", # security-analysis - "pytest>=8.0.0", # test + "bandit>=1.9.2", # security-analysis:vulnerabilities + "pip-licenses>=5.5.0", # security-analysis:licenses + "pytest>=9.0.2", # test "pytest-cov>=7.0.0", # test, coverage "pytest-xdist>=3.8.0", # test - "ruff>=0.14.10", # code-formatter, static-analysis - "ty>=0.0.6", # static-analysis + "radon>=6.0.1", # complexity:visibility + "ruff>=0.14.10", # code-formatter, static-analysis:linter + "ty>=0.0.7", # static-analysis:types + "xenon>=0.9.3", # complexity:enforcement ] [project.urls] -"documentation" = "https://aiopy.github.io/python-uvtask/" +"documentation" = "https://github.com/aiopy/python-uvtask/README.md" "repository" = "https://github.com/aiopy/python-uvtask" [tool.bandit] exclude_dirs = ["tests"] -skips = ["B404", "B602"] +skips = [] + +[tool.coverage.paths] +source = ["uvtask"] +[tool.coverage.run] +data_file = ".venv/var/coverage/.coverage" +disable_warnings = ["no-data-collected"] +source = ["tests"] +[tool.coverage.report] +fail_under = 85 +[tool.coverage.html] +directory = ".venv/var/coverage" [tool.pip-licenses] allow-only = "Apache;BSD;MIT;MPL" @@ -73,12 +86,12 @@ ignore-packages = [ partial-match = true [tool.pytest.ini_options] -cache_dir = "var/pytest" +cache_dir = ".venv/var/pytest" addopts = "-q -n 1 -p no:warnings --no-cov-on-fail" testpaths = ["tests"] [tool.ruff] -cache-dir = "var/ruff" +cache-dir = ".venv/var/ruff" line-length = 160 target-version = "py313" respect-gitignore = true @@ -94,8 +107,6 @@ lint.select = [ "RUF", # ruff-specific ] lint.ignore = [ - "PLC0415", - "PLR0912", "PLR0913", "PLR2004", ] @@ -112,7 +123,7 @@ force-single-line = false known-first-party = ["uvtask"] [tool.ruff.lint.mccabe] -max-complexity = 20 +max-complexity = 10 [tool.ty.environment] python-version = "3.13" @@ -120,7 +131,6 @@ python-version = "3.13" [tool.ty.src] exclude = [ "tests/fixtures/**", - "var", ".venv", ] @@ -130,26 +140,21 @@ upgrade-install = { command = "uv sync --frozen --no-dev --upgrade --refresh", d dev-install = { command = "uv sync --dev --all-extras", description = "Install all dependencies including dev and extras" } upgrade-dev-install = { command = "uv sync --dev --all-extras --upgrade --refresh", description = "Upgrade and refresh installation of all dependencies including dev and extras" } code-formatter = { command = "uv run ruff format uvtask tests", description = "Format code with ruff" } -"security-analysis" = { command = ["security-analysis:licenses", "security-analysis:vulnerabilities"], description = "Run all security analysis checks" } +security-analysis = { command = ["security-analysis:licenses", "security-analysis:vulnerabilities"], description = "Run all security analysis checks" } "security-analysis:licenses" = { command = "uv run pip-licenses", description = "Check third-party dependencies licenses using pip-licenses" } "security-analysis:vulnerabilities" = { command = "uv run bandit -r -c pyproject.toml uvtask tests", description = "Scan code for security vulnerabilities using bandit" } -"static-analysis" = { command = ["static-analysis:linter", "static-analysis:types"], description = "Run all static analysis checks" } +static-analysis = { command = ["static-analysis:linter", "static-analysis:types"], description = "Run all static analysis checks" } "static-analysis:linter" = { command = "uv run ruff check uvtask tests", description = "Run linter checks using ruff" } "static-analysis:types" = { command = "uv run ty check uvtask tests", description = "Run type checks using ty" } +complexity = { command = ["complexity:visibility", "complexity:enforcement"], description = "Run complexity analysis and enforcement" } +"complexity:visibility" = { command = ["uv run radon cc uvtask -a -nb", "uv run radon mi uvtask -nb"], description = "Show cyclomatic complexity and maintainability metrics using radon" } +"complexity:enforcement" = { command = "uv run xenon --max-absolute C --max-modules B --max-average A uvtask", description = "Ensure code complexity stays within allowed limits using xenon" } test = { command = ["unit-tests", "integration-tests", "functional-tests"], description = "Run all tests with pytest" } unit-tests = { command = "uv run pytest tests/unit", description = "Run unit tests with pytest" } integration-tests = { command = "uv run pytest tests/integration", description = "Run integration tests with pytest" } functional-tests = { command = "uv run pytest -n1 tests/functional", description = "Run functional tests with pytest" } coverage = { command = "uv run pytest -n1 --cov --cov-report=html", description = "Run tests with coverage report in HTML using pytest" } -clean = { command = """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) -" -""", description = "Clean build artifacts" } +clean = { command = """python -c "import shutil, glob; [shutil.rmtree(p, ignore_errors=True) for pattern in ['build', 'dist', '*.egg-info', '**/__pycache__'] for p in glob.glob(pattern, recursive=True)]" """, description = "Remove build artifacts and cache directories" } [project.scripts] uvtask = "uvtask.cli:main" diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 9c5cc59..adb3e3f 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from unittest.mock import MagicMock @@ -10,8 +11,6 @@ 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() @@ -42,8 +41,6 @@ def test_run_missing_pyproject(self, temp_dir: Path) -> None: def test_run_validates_reserved_commands(self, pyproject_toml: Path) -> None: original_cwd = Path.cwd() try: - import os - os.chdir(pyproject_toml.parent) mock_script_loader = MagicMock() mock_script_loader.load_scripts_with_descriptions.return_value = ({"help": "echo help"}, {}) @@ -75,8 +72,6 @@ def test_run_validates_reserved_commands(self, pyproject_toml: Path) -> None: def test_run_executes_command(self, pyproject_toml: Path) -> None: original_cwd = Path.cwd() try: - import os - os.chdir(pyproject_toml.parent) mock_script_loader = MagicMock() mock_script_loader.load_scripts_with_descriptions.return_value = ( @@ -117,8 +112,6 @@ def test_run_executes_command(self, pyproject_toml: Path) -> None: def test_run_handles_help_command(self, pyproject_toml: Path) -> None: original_cwd = Path.cwd() try: - import os - os.chdir(pyproject_toml.parent) mock_script_loader = MagicMock() mock_script_loader.load_scripts_with_descriptions.return_value = ({"test": "echo test"}, {}) diff --git a/tests/unit/test_executor.py b/tests/unit/test_executor.py index 54f3996..95cb8cc 100644 --- a/tests/unit/test_executor.py +++ b/tests/unit/test_executor.py @@ -13,7 +13,7 @@ def test_execute_success(self, mock_run: MagicMock) -> None: assert exit_code == 0 mock_run.assert_called_once() - @patch("uvtask.executor.subprocess.run") + @patch("uvtask.executor.run") def test_execute_with_quiet(self, mock_run: MagicMock) -> None: mock_run.return_value = CompletedProcess(["echo", "test"], 0) executor = CommandExecutor() @@ -22,7 +22,7 @@ def test_execute_with_quiet(self, mock_run: MagicMock) -> None: 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") + @patch("uvtask.executor.run") def test_execute_with_double_quiet(self, mock_run: MagicMock) -> None: mock_run.return_value = CompletedProcess(["echo", "test"], 0) executor = CommandExecutor() diff --git a/uv.lock b/uv.lock index c60a637..7d74d45 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/1a/5b0320642cca53a473e79c7d273071b5a9a8578f9e370b74da5daa2768d7/bandit-1.9.2-py3-none-any.whl", hash = "sha256:bda8d68610fc33a6e10b7a8f1d61d92c8f6c004051d5e946406be1fb1b16a868", size = 134377, upload-time = "2025-11-23T21:36:17.39Z" }, ] +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -96,6 +146,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -105,6 +164,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "mando" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -256,6 +327,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "radon" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mando" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -295,6 +394,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "stevedore" version = "5.6.0" @@ -329,6 +437,15 @@ wheels = [ { 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 = "urllib3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + [[package]] name = "uvtask" version = "0.0.0" @@ -341,8 +458,10 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, + { name = "radon" }, { name = "ruff" }, { name = "ty" }, + { name = "xenon" }, ] [package.metadata] @@ -351,11 +470,13 @@ dev = [ dev = [ { name = "bandit", specifier = ">=1.9.2" }, { name = "pip-licenses", specifier = ">=5.5.0" }, - { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "radon", specifier = ">=6.0.1" }, { name = "ruff", specifier = ">=0.14.10" }, - { name = "ty", specifier = ">=0.0.6" }, + { name = "ty", specifier = ">=0.0.7" }, + { name = "xenon", specifier = ">=0.9.3" }, ] [[package]] @@ -366,3 +487,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc7 wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] + +[[package]] +name = "xenon" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "radon" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/7c/2b341eaeec69d514b635ea18481885a956d196a74322a4b0942ef0c31691/xenon-0.9.3.tar.gz", hash = "sha256:4a7538d8ba08aa5d79055fb3e0b2393c0bd6d7d16a4ab0fcdef02ef1f10a43fa", size = 9883, upload-time = "2024-10-21T10:27:53.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/5d/29ff8665b129cafd147d90b86e92babee32e116e3c84447107da3e77f8fb/xenon-0.9.3-py2.py3-none-any.whl", hash = "sha256:6e2c2c251cc5e9d01fe984e623499b13b2140fcbf74d6c03a613fa43a9347097", size = 8966, upload-time = "2024-10-21T10:27:51.121Z" }, +] diff --git a/uvtask/commands.py b/uvtask/commands.py index a81683d..218e9d1 100644 --- a/uvtask/commands.py +++ b/uvtask/commands.py @@ -112,7 +112,7 @@ def handle_help( 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: + def _validate_command_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") print(f"{error_text}: unknown command '{color_service.yellow(command_name)}'", file=stderr) @@ -126,38 +126,38 @@ def _show_command_help(self, command_name: str, scripts: dict[str, str | list[st exit(1) + def _print_description_or_example(self, command_name: str, scripts: dict[str, str | list[str]], script_descriptions: dict[str, str]) -> None: 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) + return + 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, "...") + 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) + + def _print_usage_info(self, command_name: str) -> None: + command_cmd = color_service.bold_teal(command_name) if preference_manager.supports_color() else command_name 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]" @@ -167,6 +167,11 @@ def _show_command_help(self, command_name: str, scripts: dict[str, str | list[st 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() + + def _show_command_help(self, command_name: str, scripts: dict[str, str | list[str]], script_descriptions: dict[str, str]) -> None: + self._validate_command_exists(command_name, scripts) + self._print_description_or_example(command_name, scripts, script_descriptions) + self._print_usage_info(command_name) exit(0) def _show_general_help(self, parser: "CustomArgumentParser") -> None: @@ -188,6 +193,36 @@ def __init__( self._executor = executor self._verbose = verbose_output or VerboseOutputHandler() + def _execute_pre_hooks(self, pre_hooks: list[str], quiet_count: int, verbose_count: int) -> None: + 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) + + def _execute_main_commands(self, main_commands: list[str], quiet_count: int, verbose_count: int) -> int: + 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 + return main_exit_code + + def _execute_post_hooks(self, post_hooks: list[str], main_exit_code: int, quiet_count: int, verbose_count: int) -> int: + 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) + return main_exit_code + def execute( self, command_name: str, @@ -199,32 +234,9 @@ def execute( ) -> 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._execute_pre_hooks(pre_hooks, quiet_count, verbose_count) + main_exit_code = self._execute_main_commands(main_commands, quiet_count, verbose_count) + main_exit_code = self._execute_post_hooks(post_hooks, main_exit_code, quiet_count, verbose_count) self._verbose.show_final_exit_code(main_exit_code, verbose_count) exit(main_exit_code) except KeyboardInterrupt: diff --git a/uvtask/executor.py b/uvtask/executor.py index 2413c38..f8dfd97 100644 --- a/uvtask/executor.py +++ b/uvtask/executor.py @@ -1,13 +1,38 @@ from __future__ import annotations -import subprocess -from subprocess import run +from subprocess import DEVNULL, run # nosec B404 from sys import stderr from uvtask.colors import color_service, preference_manager class CommandExecutor: + def _print_verbose_command(self, command: str) -> None: + cmd_text = color_service.bold_teal(f"Running: {command}") if preference_manager.supports_color() else f"Running: {command}" + print(cmd_text, file=stderr) + + def _print_verbose_exit_code(self, exit_code: int) -> None: + exit_text = color_service.bold_green(f"Exit code: {exit_code}") if exit_code == 0 else color_service.bold_red(f"Exit code: {exit_code}") + print(exit_text, file=stderr) + + def _execute_quiet(self, command: str, quiet_count: int, verbose_count: int) -> int: + result = run( + command, + shell=True, + check=False, + stdout=DEVNULL if quiet_count >= 1 else None, + stderr=DEVNULL if quiet_count >= 2 else None, + ) # nosec B602 + if verbose_count > 0: + self._print_verbose_exit_code(result.returncode) + return result.returncode + + def _execute_normal(self, command: str, verbose_count: int) -> int: + result = run(command, check=False, shell=True) # nosec B602 + if verbose_count > 0: + self._print_verbose_exit_code(result.returncode) + return result.returncode + def execute( self, command: str, @@ -16,35 +41,11 @@ def execute( ) -> 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) + self._print_verbose_command(command) 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 + return self._execute_quiet(command, quiet_count, verbose_count) + return self._execute_normal(command, verbose_count) except KeyboardInterrupt: if verbose_count > 0: print(color_service.bold_red("Interrupted by user"), file=stderr) diff --git a/uvtask/formatters.py b/uvtask/formatters.py index e00f072..d95e315 100644 --- a/uvtask/formatters.py +++ b/uvtask/formatters.py @@ -4,13 +4,13 @@ import re from collections.abc import Iterable from sys import exit, stderr -from typing import ClassVar, NoReturn +from typing import ClassVar, NoReturn, Sequence from uvtask.colors import color_service, preference_manager class CommandMatcher: - def find_similar(self, command: str, available_commands: list[str]) -> str | None: + def find_similar(self, command: str, available_commands: list[str]) -> str | None: # noqa: C901 if not available_commands: return None @@ -79,13 +79,17 @@ class OptionSorter: } @classmethod - def sort(cls, lines: list[str]) -> list[str]: + def _is_option_line(cls, line: str, stripped: str) -> bool: + return bool(stripped and (stripped.startswith("-") or (line.startswith(" ") and not line.startswith(" ") and stripped))) + + @classmethod + def _group_options(cls, lines: list[str]) -> list[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)) + is_option_line = cls._is_option_line(line, stripped) if is_option_line: if current_option: @@ -103,16 +107,19 @@ def sort(cls, lines: list[str]) -> list[str]: 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 + return options - options.sort(key=get_order) + @classmethod + def _get_order(cls, option_lines: list[str]) -> int: + sorted_opts = sorted(cls._ORDER_MAP.items(), key=lambda x: len(x[0]), reverse=True) + for line in option_lines: + for opt, order in sorted_opts: + if opt in line: + return order + return 999 + @classmethod + def _flatten_options(cls, options: list[list[str]]) -> list[str]: result = [] for option in options: for line in option: @@ -120,6 +127,12 @@ def get_order(option_lines: list[str]) -> int: result.append(line) return result + @classmethod + def sort(cls, lines: list[str]) -> list[str]: + options = cls._group_options(lines) + options.sort(key=cls._get_order) + return cls._flatten_options(options) + class HelpTextProcessor: def __init__(self, ansi_stripper: AnsiStripper): @@ -130,6 +143,70 @@ def process_help_text(self, help_text: str) -> str: result = self._process_sections(lines) return self._add_section_spacing(result) + def _process_section_header(self, line: str, stripped: str) -> str: + if not preference_manager.supports_color(): + if "Global options" in stripped: + return "Global options:" + if "Commands" in stripped and ":" in stripped: + return "Commands:" + return line + + def _handle_global_options_start(self, result: list[str], processed_line: str) -> None: + if result and result[-1].strip(): + result.append("") + result.append(processed_line) + + def _handle_global_options_end(self, result: list[str], global_options_lines: list[str], line: str, stripped: str) -> None: + sorted_lines = OptionSorter.sort(global_options_lines) + result.extend(sorted_lines) + if "Use `" in stripped or "for more information" in stripped.lower(): + result.append("") + result.append(line) + + def _check_description_done(self, lines: list[str], i: int) -> bool: + if i + 1 >= len(lines): + return False + next_line = lines[i + 1] + stripped_next = self._strip(next_line) + if "Commands" in stripped_next: + return True + if not next_line.strip() and i + 2 < len(lines): + return "Commands" in self._strip(lines[i + 2]) + return False + + def _process_global_options_section( + self, line: str, stripped: str, processed_line: str, in_global_options: bool, global_options_lines: list[str], result: list[str] + ) -> tuple[bool, list[str]]: + if "Global options" in stripped: + in_global_options = True + self._handle_global_options_start(result, processed_line) + elif in_global_options and line.strip() and not line.startswith(" ") and not line.startswith(" "): + in_global_options = False + self._handle_global_options_end(result, global_options_lines, line, stripped) + global_options_lines = [] + elif in_global_options: + global_options_lines.append(line) + return in_global_options, global_options_lines + + def _process_description_section(self, line: str, lines: list[str], i: int, description_done: bool, result: list[str]) -> tuple[bool, bool]: + if description_done: + return description_done, False + if not line.strip() or "Commands" in self._strip(line): + return description_done, False + result.append(line) + if self._check_description_done(lines, i): + description_done = True + if result and result[-1].strip(): + result.append("") + return description_done, True + + def _process_commands_section(self, line: str, stripped: str, processed_line: str, usage_added: bool, result: list[str]) -> tuple[bool, bool]: + if "Commands" in stripped and not usage_added: + self._add_usage_line(result) + result.append(processed_line) + return True, True + return usage_added, False + def _process_sections(self, lines: list[str]) -> list[str]: result = [] in_global_options = False @@ -139,47 +216,27 @@ def _process_sections(self, lines: list[str]) -> list[str]: for i, line in enumerate(lines): stripped = self._strip(line) + processed_line = self._process_section_header(line, stripped) - # 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:" + new_in_global, new_global_lines = self._process_global_options_section( + line, stripped, processed_line, in_global_options, global_options_lines, result + ) + was_in_global = in_global_options + in_global_options = new_in_global + global_options_lines = new_global_lines - # 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 was_in_global or in_global_options: + continue + + description_done, handled = self._process_description_section(line, lines, i, description_done, result) + if handled: + continue + + usage_added, handled = self._process_commands_section(line, stripped, processed_line, usage_added, result) + if handled: + continue + + result.append(line) if global_options_lines: sorted_lines = OptionSorter.sort(global_options_lines) @@ -195,37 +252,43 @@ def _add_usage_line(self, result: list[str]) -> None: result.append(f"{usage_text} {prog_text} {options_text} {command_text}") result.append("") + def _is_epilog(self, stripped: str) -> bool: + return bool("Use `" in stripped and ("uvtask help" in stripped or "for more details" in stripped.lower() or "for more information" in stripped.lower())) + + def _is_section_header(self, stripped: str) -> bool: + return stripped.startswith("Usage:") or stripped in {"Commands:", "Global options:"} + + def _should_add_spacing_before(self, lines: list[str], i: int, new_lines: list[str]) -> bool: + if i == 0: + return False + prev_line = lines[i - 1] + return bool(prev_line.strip() and (not new_lines or new_lines[-1].strip())) + + def _should_keep_empty_line(self, lines: list[str], i: int) -> bool: + if i + 1 >= len(lines): + return False + next_stripped = self._strip(lines[i + 1]) + next_is_epilog = self._is_epilog(next_stripped) + next_is_section = self._is_section_header(next_stripped) + return next_is_epilog or next_is_section + 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 + is_section_header = self._is_section_header(stripped) + is_epilog = self._is_epilog(stripped) - 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_section_header and self._should_add_spacing_before(lines, i, new_lines): + new_lines.append("") if is_epilog: - if i > 0 and lines[i - 1].strip() and (not new_lines or new_lines[-1].strip()): + if self._should_add_spacing_before(lines, i, new_lines): 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) + if self._should_keep_empty_line(lines, i): + new_lines.append(line) else: new_lines.append(line) @@ -295,49 +358,65 @@ def _format_action(self, action: argparse.Action) -> str: return f" {invocation}\n {help_text}\n" return f" {invocation}\n" - def _format_subparsers(self, action: argparse._SubParsersAction) -> str: + def _calculate_max_command_width(self, action: argparse._SubParsersAction) -> int: 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 + return max_cmd_width + 2 + def _extract_choices_help(self, action: argparse._SubParsersAction) -> dict[str, str]: 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 + return choices_help + + def _format_command_line(self, choice: str, help_text: str, width: int) -> list[str]: + 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)) + return [f" {aligned_cmd}{help_text}"] + parts = [f" {cmd_name}"] + if help_text: + parts.append(f" {help_text}") + return parts + + def _format_subparsers(self, action: argparse._SubParsersAction) -> str: + width = self._calculate_max_command_width(action) + choices_help = self._extract_choices_help(action) 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}") + parts.extend(self._format_command_line(choice, help_text, width)) return "\n".join(parts) + "\n" if parts else "" + def _get_custom_help_text(self, option_strings: Sequence[str]) -> str | None: + help_map = { + ("--help", "-h"): "Display the concise help for this command", + ("--quiet", "-q"): "Use quiet output", + ("--verbose", "-v"): "Use verbose output", + ("--version", "-V"): "Display the uvtask version", + } + for options, text in help_map.items(): + if any(opt in option_strings for opt in options): + return text + return None + 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" + custom_help = self._get_custom_help_text(action.option_strings) + if custom_help: + return custom_help return help_text diff --git a/uvtask/parser.py b/uvtask/parser.py index 0d7f452..6d5583f 100644 --- a/uvtask/parser.py +++ b/uvtask/parser.py @@ -11,6 +11,21 @@ class ArgvParser: def __init__(self, argv_list: list[str] | None = None): self._argv = argv_list if argv_list is not None else argv + def _handle_color_option(self, arg: str, skip_next: bool) -> tuple[bool, bool]: + if skip_next: + preference_manager.set_preference_from_string(arg) + return False, True + if arg == "--color": + return True, True + if arg.startswith("--color="): + color_val = arg.split("=", 1)[1] + preference_manager.set_preference_from_string(color_val) + return False, True + return False, False + + def _is_global_flag(self, arg: str) -> bool: + return arg in ["-V", "--version", "-h", "--help", "--no-hooks", "--ignore-scripts"] + def parse_global_options(self, scripts: dict[str, str | list[str]]) -> tuple[str | None, list[str], int, int]: script_args_list = [] command_name = None @@ -19,27 +34,17 @@ def parse_global_options(self, scripts: dict[str, str | list[str]]) -> tuple[str 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) + skip_next, handled = self._handle_color_option(arg, skip_next) + if handled: 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"]: + if self._is_global_flag(arg): continue if arg in scripts or arg == "help": command_name = arg