diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index b95d043..72af74e 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -29,6 +29,7 @@ jobs: version: latest-known python-version: "3.15" enable-cache: true + cache-suffix: ${{ github.job }} cache-dependency-glob: | pylock.toml pyproject.toml @@ -58,6 +59,7 @@ jobs: version: latest-known python-version: "3.15" enable-cache: true + cache-suffix: ${{ github.job }} cache-dependency-glob: | pylock.toml pyproject.toml @@ -99,6 +101,7 @@ jobs: version: latest-known python-version: "3.15" enable-cache: true + cache-suffix: ${{ github.job }} cache-dependency-glob: | pylock.toml pyproject.toml @@ -127,6 +130,7 @@ jobs: version: latest-known python-version: "3.15" enable-cache: true + cache-suffix: ${{ github.job }} cache-dependency-glob: | pylock.toml pyproject.toml @@ -145,6 +149,7 @@ jobs: version: latest-known python-version: "3.15" enable-cache: true + cache-suffix: ${{ github.job }} cache-dependency-glob: | pylock.toml pyproject.toml @@ -178,6 +183,7 @@ jobs: version: latest-known python-version: "3.15" enable-cache: true + cache-suffix: ${{ github.job }} cache-dependency-glob: | pylock.toml pyproject.toml diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 142ea12..3668ce8 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -14,6 +14,10 @@ on: description: Groups for the secondary lock; empty selects build requirements type: string default: "" + additional-lock-selection: + description: Select project dependencies and configured groups for the secondary lock + type: string + default: build-or-groups additional-matrix-python: description: Python range for the secondary lock type: string @@ -67,6 +71,7 @@ jobs: lock-groups: ${{ inputs.lock-groups }} additional-lock: ${{ inputs.additional-lock }} additional-lock-groups: ${{ inputs.additional-lock-groups }} + additional-lock-selection: ${{ inputs.additional-lock-selection }} additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} diff --git a/README.md b/README.md index a460f71..95e4349 100644 --- a/README.md +++ b/README.md @@ -157,12 +157,17 @@ jobs: The required `lock-groups` input lists the caller's release dependency groups, separated by spaces or newlines. Include `build` and `lock-tools` where declared; exclude test, type, and documentation groups. This example lists this repository's groups; a caller supplies its own. The project configures its Python and platform targets in `[tool.nab]`. -For a separately checked-in build matrix lock, also pass `additional-lock` with an existing `pylock.*.toml` basename, -`additional-matrix-python` with its Python range, and `additional-matrix-platforms` and +Tests install test, type, and documentation groups afresh so they detect upstream dependency changes. The Action tests +fetch immutable, hash-checked release-tool artifacts from the shared lock; their separate package fixture resolves +test-only tools afresh. For a separately checked-in build matrix lock, also pass `additional-lock` with an existing +`pylock.*.toml` basename, `additional-matrix-python` with its Python range, and `additional-matrix-platforms` and `additional-matrix-implementations` as space-separated Nab values. The secondary lock keeps its specified environment matrix during refresh. `additional-lock-groups` selects dependency groups for it; when empty, Nab locks the project's -build requirements. These inputs have no effect when `additional-lock` is absent, and matrix inputs without that lock -fail. The writer accepts only the named secondary lock path. +build requirements. Set `additional-lock-selection: project` to include the project's runtime dependencies and any +configured build group. The consumer must select its runtime group when reading that lock. This selection cannot be +combined with `additional-lock-groups`. Secondary-lock inputs without `additional-lock` fail. The writer accepts only +the named secondary lock path. Use `project` only when those runtime dependencies become part of the published artifact, +such as a bundled zipapp; tests still install runtime dependencies afresh. For a repository that already commits `pylock.build.toml`, the complete caller is: diff --git a/changelog.d/+lock-cutoff.security.md b/changelog.d/+lock-cutoff.security.md new file mode 100644 index 0000000..5e32fef --- /dev/null +++ b/changelog.d/+lock-cutoff.security.md @@ -0,0 +1 @@ +Enforce the seven-day Nab cutoff for global and package rules, including disabled or shortened cutoffs. diff --git a/changelog.d/+release-asset-retry.bugfix.md b/changelog.d/+release-asset-retry.bugfix.md new file mode 100644 index 0000000..6ab5b05 --- /dev/null +++ b/changelog.d/+release-asset-retry.bugfix.md @@ -0,0 +1 @@ +Remove partial GitHub Release assets before retrying an upload in the same run. diff --git a/changelog.d/+release-tool-setup.bugfix.md b/changelog.d/+release-tool-setup.bugfix.md new file mode 100644 index 0000000..d06c8e4 --- /dev/null +++ b/changelog.d/+release-tool-setup.bugfix.md @@ -0,0 +1 @@ +Allow repeated lock-tool setup on Windows and check caller release-creator versions against the selected groups. diff --git a/changelog.d/+runtime-secondary-lock.feature.md b/changelog.d/+runtime-secondary-lock.feature.md new file mode 100644 index 0000000..6829949 --- /dev/null +++ b/changelog.d/+runtime-secondary-lock.feature.md @@ -0,0 +1 @@ +Refresh a secondary pylock for project runtime dependencies, including a separate Python and platform matrix. diff --git a/install-nab/action.yml b/install-nab/action.yml index e2d0788..eea0a4a 100644 --- a/install-nab/action.yml +++ b/install-nab/action.yml @@ -5,10 +5,7 @@ runs: steps: - name: Create lock-tool environment shell: bash - run: | - if [[ ! -x "$RUNNER_TEMP/lock-tools/bin/python" ]]; then - uv venv --python 3.15 "$RUNNER_TEMP/lock-tools" - fi + run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" - name: Install lock reader shell: bash run: | diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index 352cf94..f313aac 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -172,9 +172,21 @@ runs: if jq -e '.[0].draft' "$RUNNER_TEMP/release-current.json" >/dev/null; then for asset in "$ASSETS_DIRECTORY"/*; do name=${asset##*/} - if ! jq -e --arg name "$name" 'any(.[0].assets[]; .name == $name)' \ + if ! jq -e --arg name "$name" --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' + any(.[0].assets[]; .name == $name and .state == "uploaded" and + (.digest as $digest | any($assets[0][]; .name == $name and .digest == $digest))) + ' \ "$RUNNER_TEMP/release-current.json" >/dev/null; then for attempt in {1..6}; do + if ((attempt > 1)); then + release=$(gh api "repos/$GH_REPO/releases/$release_id") + asset_ids=$(jq -r --arg name "$name" \ + '.assets[] | select(.name == $name and .state != "uploaded") | .id' <<<"$release") + while IFS= read -r asset_id; do + [[ -n "$asset_id" ]] || continue + gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" >/dev/null + done <<<"$asset_ids" + fi if gh release upload "$TAG" "$asset" --repo "$GH_REPO"; then break fi diff --git a/pylock.toml b/pylock.toml index 198455d..35bfd47 100644 --- a/pylock.toml +++ b/pylock.toml @@ -2335,18 +2335,17 @@ command-line = [ "--groups", "build", "lock-tools", - "release-attestations", - "release-check", - "release-hook-check", - "release-hooks", - "release-news", "release-notes", + "release-news", "release-prepare", - "release-shell", "release-validate", + "release-check", + "release-attestations", + "release-shell", + "release-hooks", + "release-hook-check", "--output", "pylock.toml", - "--upgrade", ] input-path = "pyproject.toml" mode = "universal" diff --git a/pyproject.toml b/pyproject.toml index b435dec..054f645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ release-shell = [ "prek>=0.2", "yq>=4.4.5" ] release-hooks = [ "release-creator>=0.6.0", "prek>=0.2" ] release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ + "certifi", + "check-wheel-contents>=0.6.3", "hatchling>=1.28", "pytest>=9", "pytest-httpserver", @@ -29,6 +31,7 @@ test = [ "tox>=4.64.7", "tox-uv>=1.36.1", "trustme", + "twine>=7", "ty", ] diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index f35f3ad..3b8480c 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -10,6 +10,9 @@ inputs: additional-lock-groups: description: Groups for the secondary lock; empty selects build requirements default: "" + additional-lock-selection: + description: Select project dependencies and configured groups for the secondary lock + default: build-or-groups additional-matrix-python: description: Python range for the secondary lock default: "" @@ -48,6 +51,7 @@ runs: MODE: ${{ inputs.mode }} ADDITIONAL_LOCK: ${{ inputs.additional-lock }} ADDITIONAL_GROUPS: ${{ inputs.additional-lock-groups }} + ADDITIONAL_SELECTION: ${{ inputs.additional-lock-selection }} MATRIX_PYTHON: ${{ inputs.additional-matrix-python }} MATRIX_PLATFORMS: ${{ inputs.additional-matrix-platforms }} MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }} @@ -80,7 +84,8 @@ runs: "$RUNNER_TEMP/pylock-before-check.toml" pylock.toml fi if [[ -z "$ADDITIONAL_LOCK" ]]; then - if [[ -n "$ADDITIONAL_GROUPS$MATRIX_PYTHON$MATRIX_PLATFORMS$MATRIX_IMPLEMENTATIONS" ]]; then + if [[ "$ADDITIONAL_SELECTION" != build-or-groups || + -n "$ADDITIONAL_GROUPS$MATRIX_PYTHON$MATRIX_PLATFORMS$MATRIX_IMPLEMENTATIONS" ]]; then echo '::error::secondary matrix inputs require additional-lock' >&2 exit 1 fi @@ -111,8 +116,23 @@ runs: echo '::error::secondary matrix needs platforms and implementations' >&2 exit 1 fi - selection=(--build-requirements) - if ((${#selected_groups[@]})); then selection=(--groups "${selected_groups[@]}"); fi + case "$ADDITIONAL_SELECTION" in + build-or-groups) + selection=(--build-requirements) + if ((${#selected_groups[@]})); then selection=(--groups "${selected_groups[@]}"); fi + ;; + project) + if ((${#selected_groups[@]})); then + echo '::error::project secondary lock cannot select dependency groups' >&2 + exit 1 + fi + selection=() + ;; + *) + echo '::error::unknown secondary lock selection' >&2 + exit 1 + ;; + esac cp "$ADDITIONAL_LOCK" "$RUNNER_TEMP/additional-lock-before.toml" uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock pyproject.toml "${selection[@]}" \ --project-matrix-python "$MATRIX_PYTHON" \ diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 76322fd..97f7255 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -11,8 +11,11 @@ @pytest.fixture -def build_repository(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> Path: +def build_repository( + source_project: Path, nab_executable: Path, test_package_index: str, monkeypatch: pytest.MonkeyPatch +) -> Path: monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.setenv("UV_INDEX_URL", test_package_index) project = source_project / "pyproject.toml" release_requirement = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())[ "dependency-groups" @@ -21,6 +24,7 @@ def build_repository(source_project: Path, nab_executable: Path, monkeypatch: py project.read_text() + f"\n[dependency-groups]\nrelease-notes = [{json.dumps(release_requirement)}]\n" + '\n[tool.nab]\nbase-group = "project-runtime"\nbuild-group = "build"\n' + + f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n' ) run( source_project, diff --git a/tests/conftest.py b/tests/conftest.py index c21ef57..d7c5851 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,14 +5,21 @@ import ssl import subprocess import sys +import tomllib from collections.abc import Iterator, Mapping +from hashlib import sha256 +from html import escape from pathlib import Path from textwrap import dedent from typing import Final +from urllib.parse import urlsplit +from urllib.request import urlopen +import certifi import pytest import trustme import yaml +from packaging.utils import canonicalize_name from pytest_httpserver import HTTPServer from werkzeug.wrappers import Request, Response @@ -27,8 +34,7 @@ def github_api(tmp_path: Path) -> Iterator[tuple[HTTPServer, dict[str, str]]]: server.start() bundle = tmp_path / "ca.pem" authority.cert_pem.write_to_path(bundle) - if system_certs := ssl.get_default_verify_paths().cafile: - bundle.write_bytes(bundle.read_bytes() + Path(system_certs).read_bytes()) + bundle.write_bytes(bundle.read_bytes() + Path(certifi.where()).read_bytes()) yield ( server, { @@ -80,6 +86,63 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: return scripts +@pytest.fixture(scope="session") +def test_package_lock(tmp_path_factory: pytest.TempPathFactory, nab_executable: Path) -> Path: + fixture = tmp_path_factory.mktemp("fixture-lock") + (fixture / "pyproject.toml").write_text( + '[project]\nname = "release-fixture"\nversion = "0.1.0"\nrequires-python = ">=3.15,<3.16"\n' + '[dependency-groups]\nfixture = ["check-wheel-contents>=0.6.3", "hatchling>=1.28", "twine>=7"]\n' + '[tool.nab]\nmode = "universal"\nbase-group = "project-runtime"\n' + 'default-groups = []\n[tool.nab.matrix]\npython = ">=3.15,<3.16"\nplatforms = ["linux_x86_64"]\n' + ) + subprocess.run( + [str(nab_executable), "lock", "--groups", "fixture", "--output", "pylock.toml"], + cwd=fixture, + check=True, + capture_output=True, + ) + return fixture / "pylock.toml" + + +@pytest.fixture +def test_package_index(httpserver: HTTPServer, test_package_lock: Path, artifact_cache: dict[str, bytes]) -> str: + packages = [ + *tomllib.loads((Path(__file__).resolve().parents[1] / "pylock.toml").read_text())["packages"], + *tomllib.loads(test_package_lock.read_text())["packages"], + ] + listings: dict[str, list[str]] = {} + artifacts: dict[str, tuple[str, str]] = {} + for package in packages: + links = listings.setdefault(canonicalize_name(package["name"]), []) + for artifact in [*package.get("wheels", []), *([package["sdist"]] if "sdist" in package else [])]: + filename = artifact.get("name") or urlsplit(artifact["url"]).path.rsplit("/", 1)[-1] + digest = artifact["hashes"]["sha256"] + artifacts[filename] = artifact["url"], digest + links.append( + f'{escape(filename)}' + ) + for name, links in listings.items(): + httpserver.expect_request(f"/simple/{name}/").respond_with_data("\n".join(links), content_type="text/html") + for filename, (url, digest) in artifacts.items(): + + def serve(_request: Request, *, source: str = url, expected: str = digest) -> Response: + if expected not in artifact_cache: + with urlopen(source, timeout=30) as stream: + artifact_cache[expected] = stream.read() + content = artifact_cache[expected] + assert sha256(content).hexdigest() == expected + return Response(content, mimetype="application/octet-stream") + + httpserver.expect_request(f"/files/{filename}").respond_with_handler(serve) + return httpserver.url_for("/simple/") + + +@pytest.fixture(scope="session") +def artifact_cache() -> dict[str, bytes]: + return {} + + @pytest.fixture(scope="session") def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path: root = Path(__file__).resolve().parents[1] @@ -389,6 +452,7 @@ def _safe_environment() -> dict[str, str]: "LANG", "PATH", "UV_CACHE_DIR", + "UV_INDEX_URL", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 3cc274b..1854e1f 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import shutil import subprocess import tomllib @@ -110,6 +111,29 @@ def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts assert "Hash mismatch" in result.stderr +def test_install_nab_reuses_existing_environment(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + environment = {"GITHUB_ACTION_PATH": str(source / "install-nab")} + first = action(source, tmp_path, action_scripts, "install-nab", version="", **environment) + second = action(source, tmp_path, action_scripts, "install-nab", version="", **environment) + assert first.returncode == second.returncode == 0, first.stderr + second.stderr + assert (tmp_path / "lock-tools" / ("Scripts/nab.exe" if os.name == "nt" else "bin/nab")).is_file() + + +def test_install_nab_reuses_windows_shaped_environment(tmp_path: Path, action_scripts: Path) -> None: + subprocess.run(["uv", "venv", "--python", "3.15", str(tmp_path / "lock-tools")], check=True, capture_output=True) + (tmp_path / "lock-tools" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).unlink() + result = subprocess.run( + ["bash", str(action_scripts / "install-nab-0.sh")], + env=os.environ | {"RUNNER_TEMP": str(tmp_path)}, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "lock-tools/pyvenv.cfg").is_file() + + def test_install_locked_tools_rejects_empty_groups(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] result = install( @@ -158,6 +182,59 @@ def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, loca assert (result.returncode == 0) is local_source, result.stderr +def test_local_tool_source_accepts_versionless_package(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + project = tomllib.loads((source / "pylock.toml").read_text()) + package = next(item for item in project["packages"] if item["name"] == "release-creator") + lock = (source / "pylock.toml").read_text().replace(f'version = "{package["version"]}"', "", 1) + (tmp_path / "pylock.toml").write_text(lock) + result = subprocess.run( + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(tmp_path / "pylock.toml"), + "release-prepare", + "--shared-lock", + str(source / "pylock.toml"), + "--local-tool-source", + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_caller_version_check_uses_shared_package_marker(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + shared = tmp_path / "shared.toml" + lock = (source / "pylock.toml").read_text() + marker = next( + package["marker"] for package in tomllib.loads(lock)["packages"] if package["name"] == "release-creator" + ) + shared.write_text(lock.replace(marker.replace('"', '\\"'), '\\"release-shell\\" in dependency_groups')) + caller = tmp_path / "caller.toml" + caller.write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' + 'dependency-groups = ["release-shell"]\ncreated-by = "test"\npackages = []\n' + ) + result = subprocess.run( + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(caller), + "release-shell", + "--shared-lock", + str(shared), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "relock: release-creator" in result.stderr + + def test_install_locked_tools_shared_group_ignores_missing_caller_group(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] lock = (source / "pylock.toml").read_text().replace(' "release-attestations",\n', "", 1) @@ -270,12 +347,13 @@ def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_pat def test_install_locked_tools_accepts_lock_without_build_group( - tmp_path: Path, action_scripts: Path, nab_executable: Path + tmp_path: Path, action_scripts: Path, nab_executable: Path, test_package_index: str ) -> None: (tmp_path / "pyproject.toml").write_text( '[project]\nname = "tool-only"\nversion = "0.1.0"\nrequires-python = ">=3.15"\n' '[dependency-groups]\nrelease-check = ["prek>=0.2"]\n' - '[tool.nab]\nbase-group = "project-runtime"\ndefault-groups = []\n', + '[tool.nab]\nbase-group = "project-runtime"\ndefault-groups = []\n' + f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n', ) generated = subprocess.run( [str(nab_executable), "lock", "--groups", "release-check", "--output", "pylock.toml"], diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index 1822fa4..ba0e4d8 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -436,6 +436,99 @@ def test_draft_release_replaces_incomplete_asset( assert [request.method for request, _ in server.log] == ["GET", "GET", "DELETE", "GET", "GET", "GET"] +def test_failed_upload_deletes_partial_asset_before_retry( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + asset_path = assets / "fixture.zip" + asset_path.write_bytes(b"release archive") + draft = _release(notes, draft=True) + partial = _release( + notes, draft=True, assets=[{"id": "21", "name": asset_path.name, "digest": "", "state": "starter"}] + ) + complete = _release( + notes, + draft=True, + assets=[ + { + "name": asset_path.name, + "digest": f"sha256:{sha256(asset_path.read_bytes()).hexdigest()}", + "state": "uploaded", + } + ], + ) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [draft] + ) + for release in (draft, partial, partial): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) + server.expect_ordered_request(f"{prefix}/releases/assets/21", method="DELETE").respond_with_json({}) + for _ in range(3): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) + + result = _request( + repository, + tmp_path, + action_scripts, + notes, + commit, + environment | release_cli | {"GH_UPLOAD_FAILURE": "once"}, + assets=assets, + mode="stage", + ) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET"] * 5 + ["DELETE"] + ["GET"] * 2 + assert _release_calls(tmp_path) == [ + ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), + ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), + ] + + +def test_failed_asset_lookup_stops_retry( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + asset_path = assets / "fixture.zip" + asset_path.write_bytes(b"release archive") + draft = _release(notes, draft=True) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [draft] + ) + for _ in range(2): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_data(status=500) + result = _request( + repository, + tmp_path, + action_scripts, + notes, + commit, + environment | release_cli | {"GH_UPLOAD_FAILURE": "once"}, + assets=assets, + mode="stage", + ) + assert result.returncode != 0 + assert _release_calls(tmp_path) == [("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")] + + def test_failed_asset_upload_leaves_draft_unpublished( repository: Path, tmp_path: Path, @@ -454,7 +547,7 @@ def test_failed_asset_upload_leaves_draft_unpublished( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) - for _ in range(7): + for _ in range(14): server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) result = _request( repository, @@ -466,7 +559,7 @@ def test_failed_asset_upload_leaves_draft_unpublished( assets=assets, ) assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] * 9 + assert [request.method for request, _ in server.log] == ["GET"] * 14 assert ( _release_calls(tmp_path) == [("release", "upload", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] * 6 diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py index af4ef0e..62f8920 100644 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -22,14 +22,16 @@ def test_recheck_schedule_has_only_required_permissions() -> None: @pytest.mark.parametrize( - ("observed_age", "job_age", "job_conclusion", "job_name", "reruns"), + ("observed_age", "job_age", "job_conclusion", "job_name", "minimum_days", "reruns"), [ - pytest.param(8, 8, "failure", "hook-cooldown", 1, id="matured-failure"), - pytest.param(8, 8, "failure", "Hook update cooldown", 1, id="named-job"), - pytest.param(6, 6, "failure", "hook-cooldown", 0, id="recent-pull"), - pytest.param(8, 0, "failure", "hook-cooldown", 1, id="failed-rerun"), - pytest.param(31, 31, "failure", "hook-cooldown", 0, id="expired-run"), - pytest.param(8, 8, "success", "hook-cooldown", 0, id="successful-job"), + pytest.param(8, 8, "failure", "hook-cooldown", 7, 1, id="matured-failure"), + pytest.param(8, 8, "failure", "Hook update cooldown", 7, 1, id="named-job"), + pytest.param(6, 6, "failure", "hook-cooldown", 7, 0, id="recent-pull"), + pytest.param(8, 0, "failure", "hook-cooldown", 7, 1, id="failed-rerun"), + pytest.param(31, 31, "failure", "hook-cooldown", 7, 0, id="expired-run"), + pytest.param(8, 8, "success", "hook-cooldown", 7, 0, id="successful-job"), + pytest.param(8, 8, "failure", "hook-cooldown", 14, 0, id="custom-cooldown-pending"), + pytest.param(15, 15, "failure", "hook-cooldown", 14, 1, id="custom-cooldown-matured"), ], ) @pytest.mark.parametrize( @@ -44,6 +46,7 @@ def test_recheck_reruns_failed_job( job_age: int, job_conclusion: str, job_name: str, + minimum_days: int, reruns: int, bot_login: str, ) -> None: @@ -85,7 +88,7 @@ def test_recheck_reruns_failed_job( ] } ) - if 7 <= observed_age < 30: + if minimum_days <= observed_age < 30: server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( { "jobs": [ @@ -114,6 +117,7 @@ def test_recheck_reruns_failed_job( version="", GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], JOB_NAME=job_name, + MINIMUM_DAYS=str(minimum_days), **environment, ) assert result.returncode == 0, result.stderr diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index cd42887..212ae23 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -163,7 +163,112 @@ def test_recent_cutoff_requires_review(package: str, timestamp: str, accepted: b project = ( f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.{package}]\nuploaded-prior-to = "{timestamp}"\n' ) + result = _check_recent_cutoffs(project) + assert (result.returncode == 0) == accepted + if not accepted: + assert "unreviewed recent Nab cutoff" in result.stderr + + +@pytest.mark.parametrize( + ("cutoff", "message"), + [ + pytest.param('uploaded-prior-to = "P0D"', "global Nab cutoff", id="zero-days"), + pytest.param('uploaded-prior-to = "P6D"', "global Nab cutoff", id="six-days"), + pytest.param("uploaded-prior-to = false", "global Nab cutoff", id="disabled"), + ], +) +def test_recent_cutoff_rejects_weak_global_policy(cutoff: str, message: str) -> None: + result = _check_recent_cutoffs(f"[tool.nab]\n{cutoff}\n") + assert result.returncode != 0 + assert message in result.stderr + + +@pytest.mark.parametrize( + ("value", "accepted"), + [ + pytest.param('"P0D"', False, id="zero-days"), + pytest.param('"P6D"', False, id="six-days"), + pytest.param("false", False, id="disabled"), + pytest.param('"P7D"', True, id="seven-days"), + pytest.param('"P14D"', True, id="fourteen-days"), + ], +) +def test_recent_cutoff_checks_package_duration(value: str, accepted: bool) -> None: + project = f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.example]\nuploaded-prior-to = {value}\n' + result = _check_recent_cutoffs(project) + assert (result.returncode == 0) is accepted + if not accepted: + assert "weakens the seven-day policy" in result.stderr + + +@pytest.mark.parametrize("package", ["nab", "release-creator", "uv-build"], ids=["nab", "creator", "uv-build"]) +@pytest.mark.parametrize("value", ['"P0D"', "false"], ids=["zero-days", "disabled"]) +def test_reviewed_cutoff_rejects_unbounded_override(package: str, value: str) -> None: + project = f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.{package}]\nuploaded-prior-to = {value}\n' + result = _check_recent_cutoffs(project) + assert result.returncode != 0 + assert "weakens the seven-day policy" in result.stderr + + +def test_recent_cutoff_rejects_index_override() -> None: + result = _check_recent_cutoffs( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[[tool.nab.indexes]]\nname = "pypi"\nurl = "https://pypi.org/simple/"\n' + "[tool.nab.index.pypi]\nuploaded-prior-to = false\n" + ) + assert result.returncode != 0 + assert "Nab index cutoff for pypi" in result.stderr + + +def test_recent_cutoff_rejects_project_nab_toml(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') + (tmp_path / "nab.toml").write_text("[packages.example]\nuploaded-prior-to = false\n") + result = subprocess.run( + [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/check_recent_cutoffs.py"), "pyproject.toml"], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "project nab.toml can override" in result.stderr + + +def test_verify_cutoff_patch_reports_invalid_boolean(cutoff_project: Path) -> None: + cutoff_project.write_text(cutoff_project.read_text().replace('"2026-01-01T00:00:00Z"', "true", 1)) + subprocess.run(["git", "add", "pyproject.toml"], cwd=cutoff_project.parent, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "user.name=Release test", "-c", "user.email=release@example.org", "commit", "-m", "Invalid"], + cwd=cutoff_project.parent, + check=True, + capture_output=True, + ) result = subprocess.run( + [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/verify_cutoff_patch.py")], + cwd=cutoff_project.parent, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "invalid Nab cutoff: True" in result.stderr + + +def test_expire_cutoffs_preserves_disabled_package(cutoff_project: Path) -> None: + cutoff_project.write_text(cutoff_project.read_text().replace('"2999-01-01T00:00:00Z"', "false")) + source = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert tomllib.loads(cutoff_project.read_text())["tool"]["nab"]["packages"]["new"]["uploaded-prior-to"] is False + + +def _check_recent_cutoffs(project: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( [ sys.executable, "-c", @@ -178,6 +283,3 @@ def test_recent_cutoff_requires_review(package: str, timestamp: str, accepted: b text=True, check=False, ) - assert (result.returncode == 0) == accepted - if not accepted: - assert "unreviewed recent Nab cutoff" in result.stderr diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index dcff75e..6c58d70 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -57,7 +57,7 @@ def test_upgrade_pull_request_uses_repository_app() -> None: @pytest.fixture -def lock_repository(tmp_path: Path) -> Path: +def lock_repository(tmp_path: Path, test_package_index: str) -> Path: repository = tmp_path / "repository" repository.mkdir() (repository / "pyproject.toml").write_text( @@ -66,7 +66,8 @@ def lock_repository(tmp_path: Path) -> Path: '[tool.nab]\nmode = "universal"\nuploaded-prior-to = "P7D"\n' 'decision-order = "stable"\nbase-group = "project-runtime"\ndefault-groups = []\n' '[tool.nab.matrix]\npython = ">=3.15,<3.16"\n' - 'platforms = ["linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64"]\n', + 'platforms = ["linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64"]\n' + f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n', ) run(repository, "git", "init", "-b", "main") run(repository, "git", "config", "user.name", "Release test") @@ -96,7 +97,10 @@ def test_upgrade_locks_only_selected_groups(lock_repository: Path, tmp_path: Pat def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: project = lock_repository / "pyproject.toml" - project.write_text(project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')) + project.write_text( + project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]') + + '[tool.nab.packages.old]\nuploaded-prior-to = "2020-01-01T00:00:00Z"\n' + ) installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") assert installed.returncode == 0, installed.stderr refreshed = execute( @@ -108,30 +112,42 @@ def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path ) assert refreshed.returncode == 0, refreshed.stderr expected = (lock_repository / "pylock.toml").read_bytes() - checked = execute( + project_bytes = project.read_bytes() + run(lock_repository, "git", "add", ".") + run(lock_repository, "git", "commit", "-m", "Record lock") + (lock_repository / "pylock.toml").write_bytes(expected.replace(b'"release-check"', b'"release-news"', 1)) + stale = (lock_repository / "pylock.toml").read_bytes() + checked = action( lock_repository, - upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, + upgrade_scripts, + "refresh-release-lock", + version="", LOCK_GROUPS="release-check", MODE="check", ) - assert checked.returncode == 0, checked.stderr - assert (lock_repository / "pylock.toml").read_bytes() == expected + assert checked.returncode != 0 + assert "regenerated Nab lock differs" in checked.stderr + assert (lock_repository / "pylock.toml").read_bytes() != stale + assert project.read_bytes() == project_bytes @pytest.mark.parametrize( - ("groups", "selection"), + ("groups", "selection", "source"), [ - pytest.param("", "--build-requirements", id="build-only"), - pytest.param("release-check", "--groups", id="selected-group"), + pytest.param("", "--build-requirements", "build-or-groups", id="build-only"), + pytest.param("release-check", "--groups", "build-or-groups", id="selected-group"), + pytest.param("", "", "project", id="project-runtime"), ], ) def test_upgrade_preserves_secondary_lock_matrix( - lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, groups: str, selection: str + lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, groups: str, selection: str, source: str ) -> None: project = lock_repository / "pyproject.toml" project.write_text( - project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]') + project.read_text() + .replace('version = "0.1.0"', 'version = "0.1.0"\ndependencies = ["packaging>=26"]') + .replace("release = []", 'release-check = ["prek>=0.2"]') + '[build-system]\nrequires = ["setuptools>=80"]\nbuild-backend = "setuptools.build_meta"\n', ) installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") @@ -154,8 +170,9 @@ def test_upgrade_preserves_secondary_lock_matrix( "nab", "lock", "pyproject.toml", - selection, ] + if selection: + command.append(selection) if groups: command.append(groups) command.extend( @@ -179,6 +196,7 @@ def test_upgrade_preserves_secondary_lock_matrix( "MODE": "check", "ADDITIONAL_LOCK": additional.name, "ADDITIONAL_GROUPS": groups, + "ADDITIONAL_SELECTION": source, "MATRIX_PYTHON": ">=3.15,<3.16", "MATRIX_PLATFORMS": "linux_x86_64 macos_arm64", "MATRIX_IMPLEMENTATIONS": "cpython", @@ -186,6 +204,10 @@ def test_upgrade_preserves_secondary_lock_matrix( checked = execute(lock_repository, upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, **environment) assert checked.returncode == 0, checked.stderr assert additional.read_bytes() == expected + if source == "project": + names = {package["name"] for package in tomllib.loads(expected.decode())["packages"]} + assert "packaging" in names + assert "setuptools" not in names def test_upgrade_rejects_secondary_matrix_change(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: @@ -471,6 +493,7 @@ def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-release-lock"), "ADDITIONAL_LOCK": "", "ADDITIONAL_GROUPS": "", + "ADDITIONAL_SELECTION": "build-or-groups", "MATRIX_PYTHON": "", "MATRIX_PLATFORMS": "", "MATRIX_IMPLEMENTATIONS": "", diff --git a/tests/validate_python_distribution/test_distribution.py b/tests/validate_python_distribution/test_distribution.py index 48edea9..c124fc8 100644 --- a/tests/validate_python_distribution/test_distribution.py +++ b/tests/validate_python_distribution/test_distribution.py @@ -9,11 +9,15 @@ @pytest.fixture -def distribution(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: +def distribution( + source_project: Path, nab_executable: Path, test_package_index: str, monkeypatch: pytest.MonkeyPatch +) -> tuple[Path, Path]: monkeypatch.delenv("SSL_CERT_FILE", raising=False) project = source_project / "pyproject.toml" project.write_text( - project.read_text() + '\n[dependency-groups]\npkg-meta = ["check-wheel-contents>=0.6.3", "twine>=7"]\n', + project.read_text() + + '\n[dependency-groups]\npkg-meta = ["check-wheel-contents>=0.6.3", "twine>=7"]\n' + + f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n', ) run( source_project, diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index f6341f4..9a73156 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -7,7 +7,7 @@ from pathlib import Path from packaging.pylock import Pylock, PylockValidationError -from packaging.version import Version +from packaging.version import InvalidVersion, Version def main() -> None: @@ -18,29 +18,35 @@ def main() -> None: parser.add_argument("--local-tool-source", action="store_true") args = parser.parse_args() try: + groups = set(args.groups) with args.lock.open("rb") as stream: lock = Pylock.from_dict(tomllib.load(stream)) - if missing := set(args.groups) - set(lock.dependency_groups or ()): + if missing := groups - set(lock.dependency_groups or ()): parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") if args.shared_lock: with args.shared_lock.open("rb") as stream: shared = Pylock.from_dict(tomllib.load(stream)) - required = next(package.version for package in shared.packages if package.name == "release-creator") - installed = next((package.version for package in lock.packages if package.name == "release-creator"), None) - needs_release_creator = bool( - set(args.groups) - & {"release-news", "release-notes", "release-prepare", "release-validate", "release-hooks"} - ) - if ( - needs_release_creator - and not args.local_tool_source - and (installed is None or Version(str(installed)) < Version(str(required))) - ): - parser.error(f"relock: release-creator {required} required") - except (OSError, PylockValidationError) as error: + required = _selected_release_creator(shared, groups) + if required is not None and not args.local_tool_source: + installed = _selected_release_creator(lock, groups) + if installed is None or installed < required: + parser.error(f"relock: release-creator {required} required") + except (InvalidVersion, OSError, PylockValidationError) as error: parser.error(str(error)) +def _selected_release_creator(lock: Pylock, groups: set[str]) -> Version | None: + return next( + ( + Version(str(package.version)) + for package in lock.packages + if package.name == "release-creator" + and (package.marker is None or package.marker.evaluate({"dependency_groups": groups}, context="lock_file")) + ), + None, + ) + + if __name__ == "__main__": main() diff --git a/tools/check_recent_cutoffs.py b/tools/check_recent_cutoffs.py index a1b6cec..b9b816d 100644 --- a/tools/check_recent_cutoffs.py +++ b/tools/check_recent_cutoffs.py @@ -14,8 +14,10 @@ def main() -> None: parser.add_argument("project", type=Path) args = parser.parse_args() try: + if (args.project.parent / "nab.toml").exists(): + parser.error("project nab.toml can override the reviewed cutoff policy") validate_recent_cutoffs(args.project.read_text(), datetime.now(UTC) - timedelta(days=7)) - except (OSError, KeyError, ValueError) as error: + except (OSError, KeyError, TypeError, ValueError) as error: parser.error(str(error)) diff --git a/tools/expire_cutoffs.py b/tools/expire_cutoffs.py index 7b26a44..3ed9857 100644 --- a/tools/expire_cutoffs.py +++ b/tools/expire_cutoffs.py @@ -19,7 +19,7 @@ def main() -> None: updated = remove_expired_cutoffs(source, datetime.now(UTC) - timedelta(days=7)) if source != updated: args.project.write_text(updated) - except (OSError, KeyError, ValueError, ParseError) as error: + except (OSError, KeyError, TypeError, ValueError, ParseError) as error: parser.error(str(error)) diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index d65df7f..a4a3907 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -1,7 +1,19 @@ from __future__ import annotations +import re import tomllib from datetime import UTC, datetime +from typing import Final + +_DURATION: Final = re.compile( + r""" + ^P # ISO duration prefix + (?P[0-9]+) # Nab supports whole days + D$ # Day unit and end + """, + re.VERBOSE, +) +_MINIMUM_DAYS: Final = 7 _REVIEWED_EXCEPTIONS = { **dict.fromkeys( @@ -9,11 +21,17 @@ datetime(2026, 10, 19, tzinfo=UTC), ), "release-creator": datetime(2026, 10, 23, 2, 44, 40, tzinfo=UTC), + "uv-build": datetime(2026, 10, 21, tzinfo=UTC), # toml-fmt requires 0.13.4 until the cutoff matures. } def validate_recent_cutoffs(project: str, threshold: datetime) -> None: nab = tomllib.loads(project)["tool"]["nab"] + global_cutoff = nab.get("uploaded-prior-to") + if not isinstance(global_cutoff, str) or (match := _DURATION.fullmatch(global_cutoff)) is None: + raise ValueError("global Nab cutoff must be a duration of at least P7D") + if int(match["days"]) < _MINIMUM_DAYS: + raise ValueError("global Nab cutoff must be a duration of at least P7D") candidates = [ (name, settings["uploaded-prior-to"]) for name, settings in nab.get("packages", {}).items() @@ -25,9 +43,18 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None: if "uploaded-prior-to" in rule for name in rule["match"] ) + for name, settings in nab.get("index", {}).items(): + if "uploaded-prior-to" in settings: + raise ValueError(f"Nab index cutoff for {name} bypasses the seven-day policy") for name, value in candidates: - if isinstance(value, str) and value.startswith("P"): + if value is False: + raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}") + if isinstance(value, str) and (match := _DURATION.fullmatch(value)) is not None: + if int(match["days"]) < _MINIMUM_DAYS: + raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}") continue + if isinstance(value, bool): + raise TypeError(f"invalid Nab cutoff for {name}: {value}") timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value if timestamp.tzinfo is None: raise ValueError(f"Nab package cutoff has no timezone: {value}") @@ -66,7 +93,7 @@ def remove_cutoffs(nab: dict, names: set[str], positions: set[int]) -> None: def expired_cutoffs(project: str, threshold: datetime) -> tuple[set[str], set[int]]: nab = tomllib.loads(project)["tool"]["nab"] packages = { - name + str(name) for name, settings in nab.get("packages", {}).items() if (value := settings.get("uploaded-prior-to")) is not None and _expired(value, threshold) } @@ -78,9 +105,11 @@ def expired_cutoffs(project: str, threshold: datetime) -> tuple[set[str], set[in return packages, rules -def _expired(value: str | datetime, threshold: datetime) -> bool: - if isinstance(value, str) and value.startswith("P"): +def _expired(value: str | datetime | bool, threshold: datetime) -> bool: + if value is False or isinstance(value, str) and _DURATION.fullmatch(value) is not None: return False + if isinstance(value, bool): + raise TypeError(f"invalid Nab cutoff: {value}") timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value if timestamp.tzinfo is None: raise ValueError(f"Nab package cutoff has no timezone: {value}") diff --git a/tools/verify_cutoff_patch.py b/tools/verify_cutoff_patch.py index 57c57d5..1b6c525 100644 --- a/tools/verify_cutoff_patch.py +++ b/tools/verify_cutoff_patch.py @@ -36,7 +36,7 @@ def main() -> None: remove_cutoffs(before, removed_names, removed_positions) if original != actual: parser.error("pyproject.toml has changes beyond expired Nab cutoffs") - except (OSError, KeyError, ValueError, subprocess.CalledProcessError, tomllib.TOMLDecodeError) as error: + except (OSError, KeyError, TypeError, ValueError, subprocess.CalledProcessError, tomllib.TOMLDecodeError) as error: parser.error(str(error))