diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index d4799c10..d82cfe15 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -152,6 +152,8 @@ def classify(paths): selected.update(("cpp", "python_quality")) elif path == "tests/python/test_benchmark_protocol.py": selected.add("python_quality") + elif path == "tests/test_pyright.py": + selected.update(("python_quality", "python_build")) elif path.startswith("tests/python/"): selected.update(("python_build", "wheels")) if path == "tests/python/conftest.py": diff --git a/.github/scripts/test_ci_workflow_contracts.py b/.github/scripts/test_ci_workflow_contracts.py index b0837e91..c3ba2038 100644 --- a/.github/scripts/test_ci_workflow_contracts.py +++ b/.github/scripts/test_ci_workflow_contracts.py @@ -31,7 +31,7 @@ def test_installed_typing_checks_are_required(self): build = workflow.split(" python-build:\n", 1)[1].split( " python-result:\n", 1 )[0] - self.assertIn('pip install ".[test]"', build) + self.assertIn('pip install ".[test,typing]"', build) self.assertIn("python -m pytest tests/python/test_editor_support.py", build) self.assertNotIn("continue-on-error", build) self.assertIn( @@ -73,6 +73,48 @@ def test_installed_typing_checks_are_required(self): ) self.assertEqual(checked.returncode == 0, matrix_result == "success") + def test_pyright_checks_are_required_and_scoped(self): + config = tomllib.loads((ROOT / "pyproject.toml").read_text()) + self.assertEqual( + set(config["tool"]["pyright"]["include"]), + { + "tests/python/typing/public_api.py", + "tests/python/test_editor_support.py", + "tests/test_pyright.py", + }, + ) + self.assertEqual(config["tool"]["pyright"]["typeCheckingMode"], "standard") + self.assertTrue( + any( + dependency.startswith("pyright==") + for dependency in config["project"]["optional-dependencies"]["typing"] + ) + ) + self.assertFalse( + any( + dependency.startswith("pyright") + for dependency in config["project"]["dependencies"] + ) + ) + self.assertFalse( + any( + dependency.startswith("pyright") + for dependency in config["tool"]["cibuildwheel"]["test-requires"] + ) + ) + workflow = (ROOT / ".github/workflows/python.yml").read_text() + build = workflow.split(" python-build:\n", 1)[1].split( + " python-result:\n", 1 + )[0] + self.assertIn("actions/setup-node@v7", build) + self.assertIn('node-version: "24"', build) + self.assertIn('pip install ".[test,typing]"', build) + self.assertIn("python -m pytest tests/test_pyright.py", build) + self.assertNotIn("continue-on-error", build) + self.assertEqual( + classify(["tests/test_pyright.py"]), {"python_quality", "python_build"} + ) + def test_tidy_rejects_empty_scope_and_propagates_analysis_failure(self): with tempfile.TemporaryDirectory() as directory: work = Path(directory) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 08aa6f6a..39c751da 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -59,6 +59,11 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip + - uses: actions/setup-node@v7 + with: + node-version: "24" + package-manager-cache: false + - name: Verify runner CMake run: cmake --version @@ -66,13 +71,16 @@ jobs: run: pip install --upgrade pip - name: Build and install wheel - run: pip install ".[test]" -v + run: pip install ".[test,typing]" -v - name: Check installed Python types and editor metadata env: OMP_NUM_THREADS: "2" run: python -m pytest tests/python/test_editor_support.py --tb=short -q + - name: Check Pyright public API and test code + run: python -m pytest tests/test_pyright.py --tb=short -q + # Repaired-wheel jobs run the full Python suite. - name: Smoke-test source installation run: >- diff --git a/pyproject.toml b/pyproject.toml index c01aa53f..e5a77228 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ Paper = "https://doi.org/10.1145/3725413" [project.optional-dependencies] test = ["pytest>=7", "numpy", "mypy==2.4.0"] +typing = ["pyright==1.1.414"] [tool.scikit-build] # Disable directory auto-discovery so we can map it via CMake's install target @@ -76,3 +77,11 @@ target-version = "py311" [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "I"] + +[tool.pyright] +include = [ + "tests/python/typing/public_api.py", + "tests/python/test_editor_support.py", + "tests/test_pyright.py", +] +typeCheckingMode = "standard" diff --git a/scripts/check-python.sh b/scripts/check-python.sh index e74ed427..59092f4e 100755 --- a/scripts/check-python.sh +++ b/scripts/check-python.sh @@ -18,6 +18,6 @@ if [[ "$($ruff --version)" != "ruff 0.16.1" ]]; then fi cd "$repo_root" -paths=(python_bindings sample/python tests/python benchmarks) +paths=(python_bindings sample/python tests/python tests/test_pyright.py benchmarks) "$ruff" format --check "${paths[@]}" "$ruff" check "${paths[@]}" diff --git a/tests/README.md b/tests/README.md index 64f0be54..70375d20 100644 --- a/tests/README.md +++ b/tests/README.md @@ -338,3 +338,13 @@ python -m pytest tests/python/test_editor_support.py -q The suite checks installed declarations, signatures, documentation, examples, and valid/invalid calls with strict mypy. CI requires it; missing mypy fails the test. Keep the mypy pins in the `test` extra and wheel test requirements synchronized. + +CI also checks the public type examples and test code with Pyright. With Node 24 +and the `test,typing` extras installed in the same environment, run: + +```bash +python -m pytest tests/test_pyright.py -q +``` + +This requires clean diagnostics and verifies that every intentional invalid call +is rejected. Pyright is not required for the platform wheel test suite. diff --git a/tests/test_pyright.py b/tests/test_pyright.py new file mode 100644 index 00000000..f53dc66f --- /dev/null +++ b/tests/test_pyright.py @@ -0,0 +1,63 @@ +"""Explicit Pyright checks; run with the test and typing extras plus Node 24.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests/python/typing/public_api.py" + + +def run_pyright(*paths: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "-m", + "pyright", + "--project", + str(ROOT / "pyproject.toml"), + "--pythonpath", + sys.executable, + "--warnings", + "--outputjson", + *(str(path) for path in paths), + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_pyright_public_api_and_tests(): + result = run_pyright() + assert result.returncode == 0, result.stdout + result.stderr + report = json.loads(result.stdout) + assert report["summary"]["filesAnalyzed"] == 3, result.stdout + assert report["summary"]["errorCount"] == 0, result.stdout + assert report["summary"]["warningCount"] == 0, result.stdout + + +def test_pyright_rejects_invalid_calls(tmp_path: Path): + source = FIXTURE.read_text() + expected_lines = { + number + for number, line in enumerate(source.splitlines(), start=1) + if "# type: ignore[" in line + } + assert expected_lines + invalid = tmp_path / "invalid.py" + invalid.write_text(re.sub(r" # type: ignore\[[^]]+\]", "", source)) + result = run_pyright(invalid) + assert result.returncode == 1, result.stdout + result.stderr + report = json.loads(result.stdout) + assert report["summary"]["filesAnalyzed"] == 1, result.stdout + assert report["summary"]["warningCount"] == 0, result.stdout + actual_lines = set() + for diagnostic in report["generalDiagnostics"]: + assert diagnostic["severity"] == "error", diagnostic + assert Path(diagnostic["file"]).resolve() == invalid.resolve(), diagnostic + actual_lines.add(diagnostic["range"]["start"]["line"] + 1) + assert actual_lines == expected_lines, result.stdout