diff --git a/scripts/detect-breaking-changes b/scripts/detect-breaking-changes index bfb8d52858..cb80da6998 100755 --- a/scripts/detect-breaking-changes +++ b/scripts/detect-breaking-changes @@ -1,24 +1,30 @@ #!/usr/bin/env bash -set -e +set -euo pipefail cd "$(dirname "$0")/.." +base=$(git rev-parse --verify --end-of-options "${1:?expected a base revision}^{commit}") -echo "==> Detecting breaking changes" - -TEST_PATHS=( - tests/api_resources - tests/test_client.py - tests/test_response.py - tests/test_legacy_response.py -) +# Keep today's callers Pyright-clean before they become tomorrow's baseline. +echo '==> Checking current callers against the candidate SDK' +uv run --locked --all-extras scripts/run-pyright -for PATHSPEC in "${TEST_PATHS[@]}"; do - # Try to check out previous versions of the test files - # with the current SDK. - git checkout "$1" -- "${PATHSPEC}" 2>/dev/null || true -done +# If the tests are identical, the first run already checked the base callers. +# A change confined to tests and this script has no new SDK to compare. +test_changes=$(git diff --name-only "$base" -- tests) +other_changes=$(git diff --name-only "$base" -- . ':(exclude)tests' ':(exclude)scripts/detect-breaking-changes') +if [[ -z "$test_changes" || -z "$other_changes" ]]; then + exit 0 +fi -# Check unchanged previous-version callers with Pyright. Repository development -# uses ty; this isolated gate protects consumers who use another checker. -uv run --locked --all-extras scripts/run-pyright +# Check the complete base test tree, including its helpers, against today's +# src and checker configuration. Leave the working tree and Git index intact. +echo '==> Checking previous callers against the candidate SDK' +snapshot=$(mktemp -d) +trap 'rm -rf -- "$snapshot"' EXIT +git archive "$base" tests | tar -x -C "$snapshot" +ln -s "$PWD/src" "$snapshot/src" +mkdir "$snapshot/scripts" +config=scripts/pyrightconfig.breaking-changes.json +cp "$config" "$snapshot/$config" +PYRIGHT_PROJECT="$snapshot/$config" uv run --locked --all-extras scripts/run-pyright diff --git a/tests/test_client.py b/tests/test_client.py index b97d53574f..8626b7b54f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1120,8 +1120,8 @@ def test_binary_content_upload_with_body_is_deprecated(self, respx2_mock: MockRo @pytest.mark.parametrize("client", [False], indirect=True) def test_bare_container_response(self, respx2_mock: MockRouter, client: OpenAI) -> None: class Model(BaseModel): - metadata: dict - items: list + metadata: dict # pyright: ignore[reportMissingTypeArgument] + items: list # pyright: ignore[reportMissingTypeArgument] data = {"metadata": {"key": "value"}, "items": [1, "two"]} respx2_mock.get("/foo").mock(return_value=httpx2.Response(200, json=data)) @@ -2510,8 +2510,8 @@ async def test_binary_content_upload_with_body_is_deprecated( @pytest.mark.parametrize("async_client", [False], indirect=True) async def test_bare_container_response(self, respx2_mock: MockRouter, async_client: AsyncOpenAI) -> None: class Model(BaseModel): - metadata: dict - items: list + metadata: dict # pyright: ignore[reportMissingTypeArgument] + items: list # pyright: ignore[reportMissingTypeArgument] data = {"metadata": {"key": "value"}, "items": [1, "two"]} respx2_mock.get("/foo").mock(return_value=httpx2.Response(200, json=data)) diff --git a/tests/test_consumer_compatibility.py b/tests/test_consumer_compatibility.py new file mode 100644 index 0000000000..444f9236cb --- /dev/null +++ b/tests/test_consumer_compatibility.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +@pytest.mark.skipif(os.name != "posix", reason="The compatibility gate runs in Bash on CI") +@pytest.mark.parametrize("also_change", [None, "src/model.py", "pyproject.toml"]) +def test_compatibility_gate_selects_callers(tmp_path: Path, also_change: str | None) -> None: + scripts = tmp_path / "scripts" + scripts.mkdir() + gate = scripts / "detect-breaking-changes" + shutil.copy(Path(__file__).resolve().parents[1] / "scripts" / gate.name, gate) + (scripts / "pyrightconfig.breaking-changes.json").write_text("{}") + (tmp_path / "tests").mkdir() + (tmp_path / "src").mkdir() + (tmp_path / "src/model.py").write_text("original SDK\n") + caller = tmp_path / "tests/caller.py" + caller.write_text("previous caller\n") + + # Record the checker inputs to exercise the real Git and snapshot routing. + uv = tmp_path / "uv" + uv.write_text( + "#!/bin/sh\nset -eu\nconfig=${PYRIGHT_PROJECT:-scripts/pyrightconfig.breaking-changes.json}\n" + 'root=$(dirname "$config")/..\ncat "$root/tests/caller.py" "$root/src/model.py"\n' + ) + uv.chmod(0o755) + subprocess.run(["git", "init", "--quiet"], cwd=tmp_path, check=True) + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) + subprocess.run( + ["git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "--no-gpg-sign", "-qm", "base"], + cwd=tmp_path, + check=True, + ) + + caller.write_text("current caller\n") + if also_change is not None: + (tmp_path / also_change).write_text("changed input\n") + subprocess.run(["git", "add", also_change], cwd=tmp_path, check=True) + result = subprocess.run( + [str(gate), "HEAD"], + cwd=tmp_path, + env={**os.environ, "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}"}, + text=True, + capture_output=True, + check=True, + ) + sdk = "changed input" if also_change == "src/model.py" else "original SDK" + expected = ["current caller", sdk] + if also_change is not None: + expected.extend(["previous caller", sdk]) + assert [line for line in result.stdout.splitlines() if not line.startswith("==>")] == expected + assert caller.read_text() == "current caller\n"