diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c7a9c12..f6e87ed 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ jobs: env: EXCLUDE_NEWER: 7 days run: | - tools/install_live_tools.sh .release-tools 'prek>=0.2' 'yq>=4.4.5' + tools/install_live_tools.py .release-tools 'prek>=0.2' 'yq>=4.4.5' uv run --no-project --python .release-tools python tools/expose_locked_commands.py .release-tools - name: ๐Ÿงน Check inline shell run: | @@ -60,8 +60,8 @@ jobs: done scripts=("$script_dir"/*.sh) echo "Checking ${#scripts[@]} run blocks from the workflows and Actions with shellcheck and shfmt" - tools/cooled_prek.sh run shellcheck --files "$script_dir"/*.sh - tools/cooled_prek.sh run shfmt --files "$script_dir"/*.sh + tools/cooled_prek.py run shellcheck --files "$script_dir"/*.sh + tools/cooled_prek.py run shfmt --files "$script_dir"/*.sh test: name: ๐Ÿงช Test actions runs-on: ubuntu-26.04 diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml index 0d531d9..f36c0cb 100644 --- a/.github/workflows/news.yaml +++ b/.github/workflows/news.yaml @@ -29,7 +29,7 @@ jobs: # Temporary: release-creator 4.3.2 is younger than seven days. Delete after 2026-11-06. EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-30T15:13:19Z run: | - tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=4.1' 'towncrier>=26.9' + tools/install_live_tools.py "$RUNNER_TEMP/release-tools" 'release-creator>=4.1' 'towncrier>=26.9' uv run --no-project --python "$RUNNER_TEMP/release-tools" python tools/expose_locked_commands.py "$RUNNER_TEMP/release-tools" - name: ๐Ÿ” Validate news fragments uses: $/validate-news diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8cf1b9f..db044af 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -33,7 +33,7 @@ jobs: # Temporary: release-creator 4.3.2 is younger than seven days. Delete after 2026-11-06. EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-30T15:13:19Z run: | - tools/install_live_tools.sh .venv 'release-creator>=4.3' + tools/install_live_tools.py .venv 'release-creator>=4.3' uv run --no-project --python .venv python tools/expose_locked_commands.py .venv - name: ๐Ÿ“ Render release notes uses: $/render-release-notes diff --git a/changelog.d/+cooled-prek-fstring-pins.bugfix.md b/changelog.d/+cooled-prek-fstring-pins.bugfix.md new file mode 100644 index 0000000..dfc8361 --- /dev/null +++ b/changelog.d/+cooled-prek-fstring-pins.bugfix.md @@ -0,0 +1,2 @@ +`cooled-prek` admits exact PyPI pins built from static f-strings in hook `setup.py` files, so a Dependabot bump of +`typos` can install while the pinned release is younger than the seven-day cutoff. diff --git a/cooled-prek/action.yml b/cooled-prek/action.yml index 3f94b3d..e78e7b4 100644 --- a/cooled-prek/action.yml +++ b/cooled-prek/action.yml @@ -14,4 +14,4 @@ runs: run: | set -euo pipefail read -ra arguments <<<"$ARGS" - "$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" ${arguments[@]+"${arguments[@]}"} + "$GITHUB_ACTION_PATH/../tools/cooled_prek.py" ${arguments[@]+"${arguments[@]}"} diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index b34f14c..fb7bc7f 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -38,7 +38,7 @@ runs: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} run: >- - "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-reader" --only-binary :all: 'certifi>=2026.7.22' 'packaging>=26.3' + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python "$GITHUB_ACTION_PATH/../tools/install_live_tools.py" "$RUNNER_TEMP/lock-reader" --only-binary :all: 'certifi>=2026.7.22' 'packaging>=26.3' - id: selection name: โš™๏ธ Select locked groups and build constraints shell: bash diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 490c55f..50e5e2f 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -90,7 +90,7 @@ runs: *) formatter=mdformat ;; esac echo "Changelog formatter: $formatter ($FORMAT changelog $CHANGELOG)" - prek=$GITHUB_ACTION_PATH/../tools/cooled_prek.sh + prek=$GITHUB_ACTION_PATH/../tools/cooled_prek.py if ! "$prek" run "$formatter" --files "$CHANGELOG"; then echo "$formatter rewrote $CHANGELOG, running it again to confirm the result is stable" "$prek" run "$formatter" --files "$CHANGELOG" diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index aeb66d0..da5d8db 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -151,8 +151,6 @@ runs: set -euo pipefail # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. source "$GITHUB_ACTION_PATH/retry.sh" - # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. - source "$GITHUB_ACTION_PATH/../tools/plural.sh" if [[ "$MODE" == publish ]]; then echo 'Asset upload skipped: mode publish only publishes the staged draft' elif [[ -z "$ASSETS_DIRECTORY" ]]; then @@ -223,7 +221,7 @@ runs: uploaded=$((uploaded + 1)) fi done - echo "GitHub Release $TAG: uploaded $(plural "$uploaded" asset), $present already present" + echo "GitHub Release $TAG: uploaded $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$uploaded" asset), $present already present" fi fi - name: ๐Ÿ” Verify the complete asset set diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml index 3ccd94f..e7a6cee 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -35,8 +35,6 @@ runs: ALLOWED_PATHS: ${{ inputs.allowed-paths }} run: | set -euo pipefail - # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. - source "$GITHUB_ACTION_PATH/../tools/plural.sh" trusted_paths=() while IFS= read -r path || [[ -n "$path" ]]; do trusted_paths+=("$path") @@ -77,7 +75,7 @@ runs: exit 1 fi printf '%s\n' "${paths[@]}" | LC_ALL=C sort >"$RUNNER_TEMP/expected-paths" - echo "Prepared paths: $(plural "${#paths[@]}" path) allowed by the writer: ${paths[*]}" + echo "Prepared paths: $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "${#paths[@]}" path) allowed by the writer: ${paths[*]}" - name: ๐Ÿ” Verify the prepared commit shell: bash env: diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index 69059f5..685945a 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -25,7 +25,7 @@ runs: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} run: >- - "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/pypi-attestations" 'certifi>=2026.7.22' 'pypi-attestations>=0.0.30' + "$GITHUB_ACTION_PATH/../tools/install_live_tools.py" "$RUNNER_TEMP/pypi-attestations" 'certifi>=2026.7.22' 'pypi-attestations>=0.0.30' - name: ๐Ÿ” Compare published files shell: bash env: diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 21d4c10..e2268b2 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -64,7 +64,7 @@ runs: else echo "Nab: ${requirements[0]} (refresh mode takes the newest release under the cutoff)" fi - "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-tools" "${requirements[@]}" + "$GITHUB_ACTION_PATH/../tools/install_live_tools.py" "$RUNNER_TEMP/lock-tools" "${requirements[@]}" - name: ๐Ÿ”’ Resolve all project groups and extras shell: bash env: diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index c26c9c0..4443ec6 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -53,8 +53,6 @@ runs: PACKAGE: ${{ inputs.package }} run: | set -euo pipefail - # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. - source "$GITHUB_ACTION_PATH/../tools/plural.sh" args=(--changelog "$CHANGELOG" --tag "$TAG" --repository "$GITHUB_REPOSITORY") if [[ -n "$PREVIOUS" ]]; then args+=(--previous "$PREVIOUS") @@ -80,4 +78,4 @@ runs: release-creator notes "${args[@]}" >"$RUNNER_TEMP/release-notes.md" entries=$(grep -c '^- ' "$RUNNER_TEMP/release-notes.md" || true) categories=$(grep -c '^### ' "$RUNNER_TEMP/release-notes.md" || true) - echo "Release notes for $TAG: $(plural "$entries" entry entries) in $(plural "$categories" category categories) from $CHANGELOG -> $RUNNER_TEMP/release-notes.md" + echo "Release notes for $TAG: $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$entries" entry entries) in $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$categories" category categories) from $CHANGELOG -> $RUNNER_TEMP/release-notes.md" diff --git a/tests/bash32_arrays/test_empty_arrays.py b/tests/bash32_arrays/test_empty_arrays.py index ea6c2a8..3821974 100644 --- a/tests/bash32_arrays/test_empty_arrays.py +++ b/tests/bash32_arrays/test_empty_arrays.py @@ -20,8 +20,6 @@ def test_unguarded_array_expansions_are_provably_non_empty() -> None: ("refresh-release-lock/action.yml", "selected_groups"): "used only inside the non-empty check", ("render-release-notes/action.yml", "args"): "starts with three options", ("stage-jetbrains-marketplace/action.yml", "args"): "starts with the marketplace subcommand", - ("tools/install_live_tools.sh", "options"): "starts with --exclude-newer", - ("tools/install_live_tools.sh", "overrides"): "expanded only inside the :+ alternative", } found = {(path, name) for path, script in _run_scripts() for name in _unguarded_names(script)} @@ -34,8 +32,6 @@ def _run_scripts() -> Iterator[tuple[str, str]]: for step in step_list(at(load_yaml(action), "runs", "steps")): if "run" in step: yield action.relative_to(root).as_posix(), text(step["run"]) - for script in sorted((root / "tools").glob("*.sh")): - yield script.relative_to(root).as_posix(), script.read_text() for workflow in sorted(WORKFLOWS.glob("*.y*ml")): for job_value in mapping(at(load_yaml(workflow), "jobs")).values(): job = mapping(job_value) diff --git a/tests/conftest.py b/tests/conftest.py index 33b42a6..9d666d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,7 +96,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path: environment = tmp_path_factory.mktemp("nab-tools") subprocess.run( - [str(Path(__file__).resolve().parents[1] / "tools/install_live_tools.sh"), str(environment), "nab>=0.0.22"], + [str(Path(__file__).resolve().parents[1] / "tools/install_live_tools.py"), str(environment), "nab>=0.0.22"], env=os.environ | {"EXCLUDE_NEWER": "7 days"}, check=True, capture_output=True, diff --git a/tests/cooled_prek/test_cooled_prek.py b/tests/cooled_prek/test_cooled_prek.py index df732c8..2d05373 100644 --- a/tests/cooled_prek/test_cooled_prek.py +++ b/tests/cooled_prek/test_cooled_prek.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os import re import shlex @@ -14,7 +12,7 @@ from conftest import action from pytest_httpserver import HTTPServer -SCRIPT = Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh" +SCRIPT = Path(__file__).resolve().parents[2] / "tools/cooled_prek.py" def _fake_uv(directory: Path, httpserver: HTTPServer) -> None: @@ -42,7 +40,10 @@ def route_open(request, *args, **kwargs): """ ) ) - code = f"import sys; sys.path.insert(0, {str(site)!r}); import sitecustomize; exec(sys.stdin.read())" + code = ( + f"import coverage, sys; coverage.process_startup(); sys.path.insert(0, {str(site)!r}); " + f"import sitecustomize; exec(compile(sys.stdin.read(), {str(SCRIPT.with_name('hook_pin_cutoffs.py'))!r}, 'exec'))" + ) fake = directory / "uv" fake.write_text(f"#!/bin/sh\nexec {sys.executable!r} -I -c {shlex.quote(code)}\n") fake.chmod(0o755) @@ -111,6 +112,7 @@ def test_cooled_prek_action_passes_each_argument(tmp_path: Path, action_scripts: ("arguments", "install"), [ pytest.param(["run", "--all-files"], ["install-hooks"], id="every-hook"), + pytest.param(["run", "mdformat"], ["install-hooks", "mdformat"], id="named-hook-without-options"), pytest.param(["run", "mdformat", "--files", "CHANGELOG.md"], ["install-hooks", "mdformat"], id="named-hook"), ], ) @@ -126,7 +128,7 @@ def test_cooled_prek_builds_hooks_cooled_and_runs_them_with_the_caller_environme leaked = dict.fromkeys(("UV_INDEX_URL", "UV_EXCLUDE_NEWER_PACKAGE", "UV_SYSTEM_CERTS", "SSL_CERT_FILE"), "leak") subprocess.run( - [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"), *arguments], + [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.py"), *arguments], cwd=tmp_path, env=os.environ | leaked | {"PATH": f"{tmp_path}:{os.environ['PATH']}", "UV_EXCLUDE_NEWER": "1 day"}, check=True, @@ -153,6 +155,25 @@ def test_cooled_prek_builds_hooks_cooled_and_runs_them_with_the_caller_environme assert leaked.items() <= run_env.items() +def test_cooled_prek_stops_when_pin_discovery_fails(tmp_path: Path) -> None: + for name, body in (("uv", "exit 7"), ("prek", "touch ran")): + executable = tmp_path / name + executable.write_text(f"#!/bin/sh\n{body}\n") + executable.chmod(0o755) + + result = subprocess.run( + [str(SCRIPT), "run", "--all-files"], + cwd=tmp_path, + env=os.environ | {"PATH": f"{tmp_path}:{os.environ['PATH']}"}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 7 + assert not (tmp_path / "ran").exists() + + def test_cooled_prek_admits_nab_cutoff_exceptions(tmp_path: Path, httpserver: HTTPServer) -> None: fake = tmp_path / "prek" fake.write_text('#!/bin/sh\ncat "$UV_CONFIG_FILE" >config\n') @@ -164,7 +185,7 @@ def test_cooled_prek_admits_nab_cutoff_exceptions(tmp_path: Path, httpserver: HT ) subprocess.run( - [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"), "install-hooks"], + [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.py"), "install-hooks"], cwd=tmp_path, env=os.environ | {"PATH": f"{tmp_path}:{os.environ['PATH']}"}, check=True, @@ -185,7 +206,7 @@ def test_cooled_prek_without_exceptions_sets_no_config_file(tmp_path: Path, http (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') subprocess.run( - [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"), "install-hooks"], + [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.py"), "install-hooks"], cwd=tmp_path, env=os.environ | {"PATH": f"{tmp_path}:{os.environ['PATH']}", "UV_CONFIG_FILE": "/leak/uv.toml"}, check=True, @@ -251,6 +272,18 @@ def _admitted(uploaded: datetime) -> str: PIN_SOURCES = [ pytest.param({"setup.py": 'setup(install_requires=["simplint==0.7.1"])\n'}, {"language": "python"}, id="setup-py"), + pytest.param( + {"setup.py": 'SIMPLINT_VERSION = "0.7.1"\nsetup(install_requires=[f"simplint=={SIMPLINT_VERSION}"])\n'}, + {"language": "python"}, + id="setup-py-fstring", + ), + pytest.param( + { + "setup.py": 'VERSION = SIMPLINT_VERSION = "0.7.1"\nsetup(install_requires=[f"simplint=={SIMPLINT_VERSION}"])\n' + }, + {"language": "python"}, + id="setup-py-chained-fstring", + ), pytest.param( {"setup.cfg": "[options]\ninstall_requires =\n simplint==0.7.1\n"}, {"language": "python"}, id="setup-cfg" ), @@ -306,6 +339,31 @@ def test_cooled_prek_admits_pin_up_to_its_upload( {"language": "system"}, id="non-python-metadata", ), + pytest.param( + {"setup.py": 'VERSION = "0.7.1"\nsetup(install_requires=[VERSION])\n'}, + {"language": "python"}, + id="nonliteral-requirement", + ), + pytest.param( + {"setup.py": 'setup(install_requires=[f"simplint=={VERSION}"])\n'}, + {"language": "python"}, + id="unknown-fstring-value", + ), + pytest.param( + {"setup.py": 'settings.VERSION = "0.7.1"\nsetup(install_requires=[f"simplint=={VERSION}"])\n'}, + {"language": "python"}, + id="attribute-constant", + ), + pytest.param( + {"setup.py": 'setup(install_requires=[f"simplint=={version()}"])\n'}, + {"language": "python"}, + id="dynamic-fstring-value", + ), + pytest.param( + {"setup.py": 'VERSION = "0.7.1"\nsetup(install_requires=[f"simplint=={VERSION!r}"])\n'}, + {"language": "python"}, + id="formatted-fstring-value", + ), pytest.param( {"setup.py": 'setup(extras_require={"test": ["simplint==0.7.1"]}, tests_require=["simplint==0.7.1"])\n'}, {"language": "python"}, @@ -411,6 +469,18 @@ def test_cooled_prek_keeps_nab_duration_without_pin(tmp_path: Path, httpserver: assert (tmp_path / "config").read_text().splitlines() == ["[exclude-newer-package]", '"sphinx" = "P3D"'] +def test_cooled_prek_ignores_local_hooks(tmp_path: Path, httpserver: HTTPServer) -> None: + (tmp_path / ".pre-commit-config.yaml").write_text( + yaml.safe_dump({"repos": [{"repo": "local", "hooks": []}, {"repo": "meta", "hooks": []}]}) + ) + + result = _cooled_prek(tmp_path, httpserver) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "config").read_text() == "" + assert httpserver.log == [] + + @pytest.mark.parametrize( ("status", "error"), [ diff --git a/tests/install_live_tools/test_install_live_tools.py b/tests/install_live_tools/test_install_live_tools.py index 1daee39..8da5eb2 100644 --- a/tests/install_live_tools/test_install_live_tools.py +++ b/tests/install_live_tools/test_install_live_tools.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import json import os import subprocess @@ -10,7 +8,14 @@ import pytest from pytest_httpserver import HTTPServer -_SCRIPT: Final = Path(__file__).resolve().parents[2] / "tools/install_live_tools.sh" +_SCRIPT: Final = Path(__file__).resolve().parents[2] / "tools/install_live_tools.py" + + +def test_install_live_tools_requires_arguments() -> None: + result = subprocess.run([sys.executable, str(_SCRIPT)], capture_output=True, text=True, check=False) + + assert result.returncode != 0 + assert "usage: install_live_tools.py" in result.stderr @pytest.fixture @@ -26,18 +31,6 @@ def fake_uv(tmp_path: Path) -> Path: return executable -def _install(tmp_path: Path, fake_uv: Path, **environment: str) -> list[dict[str, Any]]: - calls = tmp_path / "calls.jsonl" - subprocess.run( - [str(_SCRIPT), str(tmp_path / "tools"), "nab>=0.0.22", "packaging>=26.3"], - env=os.environ - | {"PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", "UV_CALLS": str(calls)} - | environment, - check=True, - ) - return [json.loads(line) for line in calls.read_text().splitlines()] - - def test_install_live_tools_applies_the_cutoff(tmp_path: Path, fake_uv: Path) -> None: venv, install = _install(tmp_path, fake_uv, EXCLUDE_NEWER="7 days") @@ -54,6 +47,18 @@ def test_install_live_tools_applies_the_cutoff(tmp_path: Path, fake_uv: Path) -> ] +def _install(tmp_path: Path, fake_uv: Path, **environment: str) -> list[dict[str, Any]]: + calls = tmp_path / "calls.jsonl" + subprocess.run( + [str(_SCRIPT), str(tmp_path / "tools"), "nab>=0.0.22", "packaging>=26.3"], + env=os.environ + | {"PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", "UV_CALLS": str(calls)} + | environment, + check=True, + ) + return [json.loads(line) for line in calls.read_text().splitlines()] + + def test_install_live_tools_passes_each_package_cutoff(tmp_path: Path, fake_uv: Path) -> None: _, install = _install( tmp_path, @@ -95,7 +100,7 @@ def test_install_live_tools_requires_a_cutoff(tmp_path: Path, fake_uv: Path) -> ) assert result.returncode != 0 - assert "EXCLUDE_NEWER: unbound variable" in result.stderr + assert "EXCLUDE_NEWER is required" in result.stderr def test_install_live_tools_ignores_inherited_uv_settings(tmp_path: Path, fake_uv: Path) -> None: diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 4518224..3268f80 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os import shutil import subprocess @@ -101,8 +99,7 @@ def test_install_locked_tools_keeps_reader_after_nab_sync(tmp_path: Path, action def test_install_locked_tools_exposes_release_commands(tmp_path: Path, action_scripts: Path, caller: Path) -> None: - environment = tmp_path / "tools" - result = install(caller, action_scripts, tmp_path, groups="release-check", venv=environment) + result = install(caller, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") assert result.returncode == 0, result.stderr overlay = Path((tmp_path / "github-path").read_text().splitlines()[-1]) @@ -292,9 +289,12 @@ def test_install_locked_tools_installs_reader_under_cutoff(tmp_path: Path, actio def test_install_locked_tools_installs_only_binary_reader(tmp_path: Path, action_scripts: Path) -> None: - installer = tmp_path / "tools/install_live_tools.sh" + subprocess.run(["uv", "venv", "--python", "3.15", str(tmp_path / "lock-reader")], check=True, capture_output=True) + installer = tmp_path / "tools/install_live_tools.py" installer.parent.mkdir() - installer.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$RUNNER_TEMP/installed"\n') + installer.write_text( + 'import os, pathlib, sys\npathlib.Path(os.environ["RUNNER_TEMP"], "installed").write_text("\\n".join(sys.argv[1:]) + "\\n")\n' + ) installer.chmod(0o755) (tmp_path / "action").mkdir() steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "install-locked-tools/action.yml").read_text()) diff --git a/tests/plural/test_plural.py b/tests/plural/test_plural.py new file mode 100644 index 0000000..33c9a1c --- /dev/null +++ b/tests/plural/test_plural.py @@ -0,0 +1,36 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + pytest.param(["1", "path"], "1 path", id="singular"), + pytest.param(["2", "path"], "2 paths", id="regular-plural"), + pytest.param(["0", "entry", "entries"], "0 entries", id="irregular-plural"), + ], +) +def test_plural_prints_count_and_noun(arguments: list[str], expected: str) -> None: + result = subprocess.run( + [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/plural.py"), *arguments], + capture_output=True, + text=True, + check=False, + ) + + assert (result.returncode, result.stdout) == (0, expected) + + +def test_plural_requires_arguments() -> None: + result = subprocess.run( + [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/plural.py")], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "usage: plural.py" in result.stderr diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index 5439475..b6850a2 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -43,9 +43,12 @@ def lock_project(tmp_path: Path) -> Path: @pytest.fixture def fake_installer(tmp_path: Path) -> Path: - installer = tmp_path / "tools/install_live_tools.sh" + installer = tmp_path / "tools/install_live_tools.py" installer.parent.mkdir() - installer.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$RUNNER_TEMP/installed"\n') + installer.write_text( + "#!/usr/bin/env python3\nimport os, pathlib, sys\n" + 'pathlib.Path(os.environ["RUNNER_TEMP"], "installed").write_text("\\n".join(sys.argv[1:]) + "\\n")\n' + ) installer.chmod(0o755) (tmp_path / "action").mkdir() return installer diff --git a/tools/cooled_prek.py b/tools/cooled_prek.py new file mode 100755 index 0000000..c1dcff5 --- /dev/null +++ b/tools/cooled_prek.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Install prek hooks under the upload cutoff, then run them in the caller's environment.""" + +import subprocess +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from uv_environment import clean_uv_environment + + +def main() -> int: + arguments = sys.argv[1:] + if arguments[:1] != ["run"]: + return _install_hooks(arguments) + hooks = [] + for argument in arguments[1:]: + if argument.startswith("-"): + break + hooks.append(argument) + if status := _install_hooks(["install-hooks", *hooks]): + return status + print(f"Running prek {' '.join(arguments)} with the caller's environment", flush=True) + return subprocess.run(["prek", "--config", ".pre-commit-config.yaml", *arguments], check=False).returncode + + +def _install_hooks(arguments: list[str]) -> int: + environment = clean_uv_environment() | {"UV_EXCLUDE_NEWER": "7 days", "UV_NO_CONFIG": "1", "UV_NO_SOURCES": "1"} + result = subprocess.run( + ["uv", "run", "--no-project", "--with", "pyyaml", "python", "-I", "-"], + input=Path(__file__).with_name("hook_pin_cutoffs.py").read_text(), + text=True, + capture_output=True, + env=environment, + check=False, + ) + sys.stderr.write(result.stderr) + if result.returncode: + return result.returncode + exceptions = result.stdout.strip() + with TemporaryDirectory() as directory: + if exceptions: + config = Path(directory) / "uv.toml" + config.write_text(f"[exclude-newer-package]\n{exceptions}\n") + environment["UV_CONFIG_FILE"] = str(config) + print( + f"Running prek {' '.join(arguments)} with hook environments built under the 7 days cutoff, " + "repository uv settings ignored, [tool.nab] exceptions and exact hook pins admitted up to their dates: " + f"{' '.join(exceptions.splitlines())}", + flush=True, + ) + else: + print( + f"Running prek {' '.join(arguments)} with hook environments built under the 7 days cutoff, " + "repository uv settings ignored", + flush=True, + ) + return subprocess.run( + ["prek", "--config", ".pre-commit-config.yaml", *arguments], env=environment, check=False + ).returncode + + +raise SystemExit(main()) diff --git a/tools/cooled_prek.sh b/tools/cooled_prek.sh deleted file mode 100755 index ee350f8..0000000 --- a/tools/cooled_prek.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env bash -# Hook environments build from PyPI under the seven-day cutoff, ignoring the repository's uv settings. A run then -# executes the hooks with the caller's environment, so a system hook that calls uv resolves the project as usual. -set -euo pipefail -cooled() ( - unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL - unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE - unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - export UV_EXCLUDE_NEWER="7 days" UV_NO_CONFIG=1 UV_NO_SOURCES=1 - # uv pip, which prek calls, reads per-package cutoffs only from a config file, so the [tool.nab] exceptions the - # release lock already admits and the exact hook pins are written to one that holds nothing else. A pinned release - # stays admitted while it is younger than the cutoff, up to its upload time, so the pin installs and newer releases - # of the package stay blocked; its dependencies stay cooled. PyYAML comes from uv under the same cutoff because the - # runner's python3 may not have it and the hook manifests are YAML. The snippet runs under -I, which drops the - # runner's PYTHONPATH and keeps the repository directory off sys.path. - exceptions=$( - uv run --no-project --with pyyaml python -I - <<'PY' -import configparser -import datetime -import json -import pathlib -import re -import subprocess -import sys -import tempfile -import tomllib -import urllib.request - -import yaml - -HOOK_LANGUAGES = {"python", "python_venv"} -NAME = r"[A-Za-z0-9][A-Za-z0-9._-]*" -VERSION = r"[A-Za-z0-9][A-Za-z0-9.+!_-]*" -REQUIREMENT = re.compile(rf"\s*({NAME})(?:\[[^\]]*\])?==({VERSION})\s*") -SETUP_PY_INSTALL_REQUIRES = re.compile(r"install_requires\s*=\s*\[((?:[^\[\]]|\[[^\]]*\])*)\]") -QUOTED = re.compile(r"[\"']([^\"']+)[\"']") -SEVEN_DAYS_AGO = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=7) - - -def normalize(name): - return re.sub(r"[-_.]+", "-", name).lower() - - -def exact_pins(requirements): - pins = set() - for requirement in requirements: - if match := REQUIREMENT.fullmatch(requirement.split(";")[0]): - pins.add((normalize(match[1]), match[2])) - return pins - - -def setup_py_pins(text): - match = SETUP_PY_INSTALL_REQUIRES.search(text) - return exact_pins(QUOTED.findall(match[1])) if match else set() - - -def setup_cfg_pins(text): - parser = configparser.ConfigParser(interpolation=None) - parser.read_string(text) - return exact_pins(parser.get("options", "install_requires", fallback="").splitlines()) - - -def pyproject_pins(text): - return exact_pins(tomllib.loads(text).get("project", {}).get("dependencies", [])) - - -PACKAGING_READERS = {"setup.py": setup_py_pins, "setup.cfg": setup_cfg_pins, "pyproject.toml": pyproject_pins} - - -def repo_files(url, rev): - with tempfile.TemporaryDirectory() as directory: - git = ["git", "-C", directory] - try: - subprocess.run(["git", "init", "-q", directory], capture_output=True, text=True, check=True) - subprocess.run( - [*git, "fetch", "-q", "--depth", "1", "--filter=blob:none", url, rev], - capture_output=True, - text=True, - check=True, - ) - root = subprocess.run( - [*git, "ls-tree", "--name-only", "FETCH_HEAD"], capture_output=True, text=True, check=True - ).stdout.split() - wanted = {".pre-commit-hooks.yaml", *PACKAGING_READERS} - return { - name: subprocess.run( - [*git, "show", f"FETCH_HEAD:{name}"], capture_output=True, text=True, check=True - ).stdout - for name in root - if name in wanted - } - except subprocess.CalledProcessError as error: - sys.exit(f"cooled-prek: cannot fetch {rev} of {url}: {error.stderr.strip()}") - - -def hook_pins(url, rev, hooks): - files = repo_files(url, rev) - manifest = { - hook["id"]: hook.get("language") for hook in yaml.safe_load(files.get(".pre-commit-hooks.yaml", "")) or [] - } - python_hooks = [hook for hook in hooks if (hook.get("language") or manifest.get(hook["id"])) in HOOK_LANGUAGES] - if not python_hooks: - return set() - pins = exact_pins(requirement for hook in python_hooks for requirement in hook.get("additional_dependencies", [])) - for name, reader in PACKAGING_READERS.items(): - if name in files: - pins |= reader(files[name]) - return pins - - -def upload_time(url, name, version): - # Fixed PyPI origin, read with the same request as tools/verify_pypi_files.py. - request = urllib.request.Request( - f"https://pypi.org/simple/{name}/", - headers={"Accept": "application/vnd.pypi.simple.v1+json", "Cache-Control": "no-cache"}, - ) - parts = "[-_.]+".join(re.escape(part) for part in name.split("-")) - pattern = re.compile(rf"{parts}-{re.escape(version)}(?:-|\.tar\.gz$|\.zip$)", re.IGNORECASE) - try: - with urllib.request.urlopen( # ruff: ignore[suspicious-url-open-usage] # Fixed PyPI origin. - request, timeout=30 - ) as response: - files = json.load(response)["files"] - times = [ - datetime.datetime.fromisoformat(file["upload-time"]) for file in files if pattern.match(file["filename"]) - ] - except (OSError, ValueError, KeyError) as error: - sys.exit(f"cooled-prek: cannot read {name} from PyPI for the hook repository {url}: {error}") - if not times: - sys.exit(f"cooled-prek: PyPI has no files for {name}=={version}, pinned by the hook repository {url}") - return max(times) - - -def as_datetime(value): - if isinstance(value, str): - try: - value = datetime.datetime.fromisoformat(value) - except ValueError: - return None - return value if isinstance(value, datetime.datetime) and value.tzinfo is not None else None - - -path = pathlib.Path("pyproject.toml") -packages = tomllib.loads(path.read_text()).get("tool", {}).get("nab", {}).get("packages", {}) if path.is_file() else {} -cutoffs = { - normalize(name): settings["uploaded-prior-to"] - for name, settings in packages.items() - if settings.get("uploaded-prior-to") is not None -} -path = pathlib.Path(".pre-commit-config.yaml") -config = (yaml.safe_load(path.read_text()) if path.is_file() else None) or {} -pins = {} -for repo in config.get("repos", []): - if repo["repo"] in {"local", "meta"}: - continue - for name, version in hook_pins(repo["repo"], repo["rev"], repo.get("hooks", [])): - cutoff = upload_time(repo["repo"], name, version).replace(microsecond=0) + datetime.timedelta(seconds=1) - if cutoff > SEVEN_DAYS_AGO: - pins[name] = max(pins.get(name, cutoff), cutoff) -for name, cutoff in pins.items(): - current = as_datetime(cutoffs.get(name)) - if current is None or current < cutoff: - cutoffs[name] = cutoff -for name, value in sorted(cutoffs.items()): - value = value.isoformat().replace("+00:00", "Z") if hasattr(value, "isoformat") else str(value) - print(f'"{name}" = "{value}"') -PY - ) - if [[ -n "$exceptions" ]]; then - config=$(mktemp) - printf '[exclude-newer-package]\n%s\n' "$exceptions" >"$config" - export UV_CONFIG_FILE="$config" - echo "Running prek $* with hook environments built under the 7 days cutoff, repository uv settings ignored," \ - "[tool.nab] exceptions and exact hook pins admitted up to their dates: $(tr '\n' ' ' <<<"$exceptions")" - else - echo "Running prek $* with hook environments built under the 7 days cutoff, repository uv settings ignored" - fi - exec prek --config .pre-commit-config.yaml "$@" -) -if [[ "${1:-}" != run ]]; then - cooled "$@" - exit -fi -# The hook IDs a run names come before its first option; none means every hook. -hooks=() -for argument in "${@:2}"; do - [[ "$argument" == -* ]] && break - hooks+=("$argument") -done -cooled install-hooks ${hooks[@]+"${hooks[@]}"} -echo "Running prek $* with the caller's environment" -exec prek --config .pre-commit-config.yaml "$@" diff --git a/tools/hook_pin_cutoffs.py b/tools/hook_pin_cutoffs.py new file mode 100644 index 0000000..330e15e --- /dev/null +++ b/tools/hook_pin_cutoffs.py @@ -0,0 +1,206 @@ +"""Print uv cutoff exceptions for exact pins in pre-commit hooks.""" + +import ast +import configparser +import datetime +import json +import pathlib +import re +import subprocess +import sys +import tempfile +import tomllib +import urllib.request +from collections.abc import Callable, Iterable, Mapping +from typing import Final, NotRequired, TypedDict + +import yaml + + +class Hook(TypedDict): + id: str + language: NotRequired[str] + additional_dependencies: NotRequired[list[str]] + + +def normalize(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def exact_pins(requirements: Iterable[str]) -> set[tuple[str, str]]: + name = r"[A-Za-z0-9][A-Za-z0-9._-]*" + requirement_pattern = re.compile(rf"\s*({name})(?:\[[^\]]*\])?==([A-Za-z0-9][A-Za-z0-9.+!_-]*)\s*") + pins = set() + for requirement in requirements: + if match := requirement_pattern.fullmatch(requirement.split(";")[0]): + pins.add((normalize(match[1]), match[2])) + return pins + + +def setup_py_pins(text: str) -> set[tuple[str, str]]: + tree = ast.parse(text) + constants = static_constants(tree) + pins = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name) or node.func.id != "setup": + continue + for keyword in node.keywords: + if keyword.arg != "install_requires" or not isinstance(keyword.value, (ast.List, ast.Tuple)): + continue + for requirement in keyword.value.elts: + if value := static_requirement(requirement, constants): + pins |= exact_pins([value]) + return pins + + +def static_constants(tree: ast.Module) -> dict[str, str]: + constants = {} + for node in tree.body: + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): + for target in node.targets: + if isinstance(target, ast.Name): + constants[target.id] = node.value.value + return constants + + +def static_requirement(requirement: ast.expr, constants: Mapping[str, str]) -> str | None: + if isinstance(requirement, ast.Constant) and isinstance(requirement.value, str): + return requirement.value + if not isinstance(requirement, ast.JoinedStr): + return None + parts = [] + for part in requirement.values: + if isinstance(part, ast.Constant) and isinstance(part.value, str): + parts.append(part.value) + elif isinstance(part, ast.FormattedValue) and isinstance(part.value, ast.Name): + value = constants.get(part.value.id) + if value is None or part.conversion != -1 or part.format_spec is not None: + return None + parts.append(value) + else: + return None + return "".join(parts) + + +def setup_cfg_pins(text: str) -> set[tuple[str, str]]: + parser = configparser.ConfigParser(interpolation=None) + parser.read_string(text) + return exact_pins(parser.get("options", "install_requires", fallback="").splitlines()) + + +def pyproject_pins(text: str) -> set[tuple[str, str]]: + return exact_pins(tomllib.loads(text).get("project", {}).get("dependencies", [])) + + +_PACKAGING_READERS: Final[dict[str, Callable[[str], set[tuple[str, str]]]]] = { + "setup.py": setup_py_pins, + "setup.cfg": setup_cfg_pins, + "pyproject.toml": pyproject_pins, +} + + +def repo_files(url: str, rev: str) -> dict[str, str]: + with tempfile.TemporaryDirectory() as directory: + git = ["git", "-C", directory] + try: + subprocess.run(["git", "init", "-q", directory], capture_output=True, text=True, check=True) + subprocess.run( + [*git, "fetch", "-q", "--depth", "1", "--filter=blob:none", url, rev], + capture_output=True, + text=True, + check=True, + ) + root = subprocess.run( + [*git, "ls-tree", "--name-only", "FETCH_HEAD"], capture_output=True, text=True, check=True + ).stdout.split() + wanted = {".pre-commit-hooks.yaml", *_PACKAGING_READERS} + return { + name: subprocess.run( + [*git, "show", f"FETCH_HEAD:{name}"], capture_output=True, text=True, check=True + ).stdout + for name in root + if name in wanted + } + except subprocess.CalledProcessError as error: + sys.exit(f"cooled-prek: cannot fetch {rev} of {url}: {error.stderr.strip()}") + + +def hook_pins(url: str, rev: str, hooks: list[Hook]) -> set[tuple[str, str]]: + files = repo_files(url, rev) + manifest = { + hook["id"]: hook.get("language") for hook in yaml.safe_load(files.get(".pre-commit-hooks.yaml", "")) or [] + } + python_hooks = [ + hook for hook in hooks if (hook.get("language") or manifest.get(hook["id"])) in {"python", "python_venv"} + ] + if not python_hooks: + return set() + pins = exact_pins(requirement for hook in python_hooks for requirement in hook.get("additional_dependencies", [])) + for name, reader in _PACKAGING_READERS.items(): + if name in files: + pins |= reader(files[name]) + return pins + + +def upload_time(url: str, name: str, version: str) -> datetime.datetime: + # Fixed PyPI origin, read with the same request as tools/verify_pypi_files.py. + request = urllib.request.Request( + f"https://pypi.org/simple/{name}/", + headers={"Accept": "application/vnd.pypi.simple.v1+json", "Cache-Control": "no-cache"}, + ) + parts = "[-_.]+".join(re.escape(part) for part in name.split("-")) + pattern = re.compile(rf"{parts}-{re.escape(version)}(?:-|\.tar\.gz$|\.zip$)", re.IGNORECASE) + try: + with urllib.request.urlopen( # ruff: ignore[suspicious-url-open-usage] # Fixed PyPI origin. + request, timeout=30 + ) as response: + files = json.load(response)["files"] + times = [ + datetime.datetime.fromisoformat(file["upload-time"]) for file in files if pattern.match(file["filename"]) + ] + except (OSError, ValueError, KeyError) as error: + sys.exit(f"cooled-prek: cannot read {name} from PyPI for the hook repository {url}: {error}") + if not times: + sys.exit(f"cooled-prek: PyPI has no files for {name}=={version}, pinned by the hook repository {url}") + return max(times) + + +def as_datetime(value: str | datetime.datetime | None) -> datetime.datetime | None: + if isinstance(value, str): + try: + value = datetime.datetime.fromisoformat(value) + except ValueError: + return None + return value if isinstance(value, datetime.datetime) and value.tzinfo is not None else None + + +def main() -> None: + path = pathlib.Path("pyproject.toml") + packages = ( + tomllib.loads(path.read_text()).get("tool", {}).get("nab", {}).get("packages", {}) if path.is_file() else {} + ) + cutoffs = { + normalize(name): settings["uploaded-prior-to"] + for name, settings in packages.items() + if settings.get("uploaded-prior-to") is not None + } + path = pathlib.Path(".pre-commit-config.yaml") + config = (yaml.safe_load(path.read_text()) if path.is_file() else None) or {} + seven_days_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=7) + pins = {} + for repo in config.get("repos", []): + if repo["repo"] in {"local", "meta"}: + continue + for name, version in hook_pins(repo["repo"], repo["rev"], repo.get("hooks", [])): + cutoff = upload_time(repo["repo"], name, version).replace(microsecond=0) + datetime.timedelta(seconds=1) + if cutoff > seven_days_ago: + pins[name] = max(pins.get(name, cutoff), cutoff) + for name, cutoff in pins.items(): + if (current := as_datetime(cutoffs.get(name))) is None or current < cutoff: + cutoffs[name] = cutoff + for name, value in sorted(cutoffs.items()): + value = value.isoformat().replace("+00:00", "Z") if hasattr(value, "isoformat") else str(value) + print(f'"{name}" = "{value}"') + + +main() diff --git a/tools/install_live_tools.py b/tools/install_live_tools.py new file mode 100755 index 0000000..204a4cb --- /dev/null +++ b/tools/install_live_tools.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Install release tools from PyPI under an upload cutoff.""" + +import os +import subprocess +import sys + +from uv_environment import clean_uv_environment + + +def main() -> int: + if len(sys.argv) < 3: + raise SystemExit("usage: install_live_tools.py VENV REQUIREMENT [REQUIREMENT ...]") + cutoff = os.environ.get("EXCLUDE_NEWER") + if cutoff is None: + raise SystemExit("EXCLUDE_NEWER is required") + environment = clean_uv_environment() | {"UV_NO_CONFIG": "1"} + overrides = [line.strip() for line in os.environ.get("EXCLUDE_NEWER_PACKAGE", "").splitlines() if line.strip()] + options = ["--exclude-newer", cutoff] + for override in overrides: + options.extend(("--exclude-newer-package", override)) + override_summary = f", overrides: {' '.join(overrides)}" if overrides else "" + print( + f"Installing {' '.join(sys.argv[2:])} into {sys.argv[1]} from PyPI (cutoff {cutoff}{override_summary})", + flush=True, + ) + for command in ( + ["uv", "venv", "--allow-existing", "--python", "3.15", sys.argv[1]], + ["uv", "pip", "install", "--python", sys.argv[1], *options, *sys.argv[2:]], + ): + if (result := subprocess.run(command, env=environment, check=False)).returncode: + return result.returncode + return 0 + + +raise SystemExit(main()) diff --git a/tools/install_live_tools.sh b/tools/install_live_tools.sh deleted file mode 100755 index ebbdecf..0000000 --- a/tools/install_live_tools.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# The Actions' own tools come from PyPI under a cutoff, so they refresh themselves without a shared lock, ignoring the -# repository's uv settings. -set -euo pipefail -venv=$1 -shift -unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL -unset UV_FIND_LINKS UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE -unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR -export UV_NO_CONFIG=1 -options=(--exclude-newer "$EXCLUDE_NEWER") -overrides=() -while IFS= read -r line; do - read -r line <<<"$line" - if [[ -n "$line" ]]; then - options+=(--exclude-newer-package "$line") - overrides+=("$line") - fi -done <<<"${EXCLUDE_NEWER_PACKAGE:-}" -echo "Installing $* into $venv from PyPI (cutoff $EXCLUDE_NEWER${overrides[*]:+, overrides: ${overrides[*]}})" -uv venv --allow-existing --python 3.15 "$venv" -uv pip install --python "$venv" "${options[@]}" "$@" diff --git a/tools/plural.py b/tools/plural.py new file mode 100755 index 0000000..09ee1a1 --- /dev/null +++ b/tools/plural.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Print a count and its singular or plural noun.""" + +import sys + + +def main() -> None: + if len(sys.argv) not in {3, 4}: + raise SystemExit("usage: plural.py COUNT SINGULAR [PLURAL]") + count = int(sys.argv[1]) + noun = sys.argv[2] + if count != 1: + noun = sys.argv[3] if len(sys.argv) == 4 else f"{noun}s" + sys.stdout.write(f"{count} {noun}") + + +main() diff --git a/tools/plural.sh b/tools/plural.sh deleted file mode 100644 index 1c1effd..0000000 --- a/tools/plural.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -plural() { - if (($1 == 1)); then - printf '%s %s' "$1" "$2" - else - printf '%s %s' "$1" "${3:-${2}s}" - fi -} diff --git a/tools/uv_environment.py b/tools/uv_environment.py new file mode 100644 index 0000000..4c90aa0 --- /dev/null +++ b/tools/uv_environment.py @@ -0,0 +1,26 @@ +"""Remove caller uv settings from isolated tool installs.""" + +import os + + +def clean_uv_environment() -> dict[str, str]: + excluded = { + "UV_CONFIG_FILE", + "UV_INDEX", + "UV_INDEX_URL", + "UV_DEFAULT_INDEX", + "UV_EXTRA_INDEX_URL", + "UV_FIND_LINKS", + "UV_EXCLUDE_NEWER", + "UV_EXCLUDE_NEWER_PACKAGE", + "UV_INSECURE_HOST", + "UV_SYSTEM_CERTS", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + } + return {name: value for name, value in os.environ.items() if name not in excluded} + + +__all__ = [ + "clean_uv_environment", +] diff --git a/validate-news/action.yml b/validate-news/action.yml index 81a049d..e3d54cd 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -102,10 +102,8 @@ runs: env: BASE_REF: ${{ github.base_ref }} run: | - # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. - source "$GITHUB_ACTION_PATH/../tools/plural.sh" release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count" - echo "Changed fragments: $(plural "$(<"$RUNNER_TEMP/news-fragment-count")" fragment) valid against origin/$BASE_REF" + echo "Changed fragments: $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$(<"$RUNNER_TEMP/news-fragment-count")" fragment) valid against origin/$BASE_REF" - id: labels name: ๐Ÿท๏ธ Read the current labels if: steps.opening.outputs.skip != 'true' @@ -127,12 +125,10 @@ runs: SKIP_NEWS: ${{ steps.labels.outputs.skip }} BOT_AUTHOR: ${{ github.event.pull_request.user.type == 'Bot' }} run: | - # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. - source "$GITHUB_ACTION_PATH/../tools/plural.sh" fragments=0 if [[ -f "$RUNNER_TEMP/news-fragment-count" ]]; then read -r fragments <"$RUNNER_TEMP/news-fragment-count"; fi if ((fragments)); then - echo "News requirement: met by $(plural "$fragments" fragment)" + echo "News requirement: met by $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$fragments" fragment)" elif [[ "$SKIP_NEWS" == true ]]; then echo 'News requirement: waived by the skip news label' elif [[ "$BOT_AUTHOR" == true ]]; then diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index f259d17..dcc56f7 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -22,8 +22,6 @@ runs: FRAGMENTS: ${{ steps.config.outputs.fragments }} run: | set -euo pipefail - # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. - source "$GITHUB_ACTION_PATH/../tools/plural.sh" patterns=() while IFS= read -r pattern; do if [[ -n "$pattern" ]]; then @@ -90,8 +88,8 @@ runs: count=$((count + 1)) done <"$RUNNER_TEMP/release-changes" if [[ -n "$ALLOWED_PATHS" ]]; then - echo "Release patch paths: $(plural "$count" path) within the allowlist" + echo "Release patch paths: $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$count" path) within the allowlist" else - echo "Release patch paths: $(plural "$removed" fragment) removed from $FRAGMENTS" + echo "Release patch paths: $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$removed" fragment) removed from $FRAGMENTS" fi - echo "Release patch file modes: $(plural "$count" path), each a non-executable regular file or a deletion" + echo "Release patch file modes: $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$count" path), each a non-executable regular file or a deletion"