diff --git a/.github/scripts/publish/post_build.sh b/.github/scripts/publish/post_build.sh index 989a63da816..cba278f20f7 100755 --- a/.github/scripts/publish/post_build.sh +++ b/.github/scripts/publish/post_build.sh @@ -5,16 +5,15 @@ set -euo pipefail : "${PACKAGE:?}" +: "${BUILD_DIR:?}" : "${DIST_DIR:?}" -# The reflex wheel carries the generated .pyi stubs (scripts/hatch_build.py). -# A build that silently produced none would ship a release with no type -# information, so it must not reach the approver. -if [ "$PACKAGE" = "reflex" ]; then - if unzip -l "$DIST_DIR"/*.whl | grep '\.pyi$'; then - echo "✓ .pyi files found in distribution" - else - echo "Error: No .pyi files found in wheel" - exit 1 - fi -fi +# Every package whose build generates .pyi stubs — reflex itself and the +# component packages alike — must ship them, or the release carries no type +# information. Which packages those are is derived from the package being built, +# not listed anywhere: see scripts/verify_pyi.py. +# +# Located relative to this hook rather than to the working directory, which is +# what DIST_DIR is relative to. +exec uv run --no-config --script \ + "$(dirname "$0")/../../../scripts/verify_pyi.py" diff --git a/pyproject.toml b/pyproject.toml index 959df1a5633..d904d1c2daf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -269,6 +269,7 @@ preview = true "*/blank.py" = ["I001"] "docs/package/scripts/*.py" = ["INP001"] "scripts/check_min_deps.py" = ["T201"] +"scripts/verify_pyi.py" = ["T201"] [tool.pytest.ini_options] filterwarnings = "ignore:fields may not start with an underscore:RuntimeWarning" diff --git a/scripts/verify_pyi.py b/scripts/verify_pyi.py new file mode 100644 index 00000000000..3348bea215e --- /dev/null +++ b/scripts/verify_pyi.py @@ -0,0 +1,160 @@ +"""Verify that a built distribution ships the .pyi stubs its package generates. + +Reflex generates its type stubs at build time — ``scripts/hatch_build.py`` for the +root ``reflex`` package, the ``hatch-reflex-pyi`` build hook for the component +packages — and ``*.pyi`` is gitignored, so nothing but the build itself puts a +stub into an artifact. A build that silently produced none ships a release with no +type information at all, and that is not recoverable: a version can only be +uploaded to PyPI once. + +Run by ``.github/scripts/publish/post_build.sh``, the publish workflow's +repository-specific hook, with ``PACKAGE``, ``BUILD_DIR`` and ``DIST_DIR`` in the +environment — after the build and before the human approval gate. + +Which packages must ship stubs is read out of the package's own pyproject rather +than listed here, so a package that starts or stops generating them is covered +without touching this script. +""" + +# Inline dependencies, so `uv run --script` provisions the tomllib backport on the +# interpreters that lack it: the hook runs outside the project environment and can +# only rely on what this block declares. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "tomli; python_version < '3.11'", +# ] +# /// + +from __future__ import annotations + +import os +import sys +import tarfile +import zipfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib # pyright: ignore[reportMissingImports] + +#: The hatch build hook (packages/hatch-reflex-pyi) that a component package +#: declares to have its stubs generated during the build. +PYI_HOOK = "reflex-pyi" + +#: Suffixes of the built artifacts that must carry a stub-shipping package's stubs. +ARTIFACT_SUFFIXES = (".whl", ".tar.gz") + + +def build_table(package_dir: Path) -> Mapping[str, Any]: + """Read a package's ``[tool.hatch.build]`` table. + + Args: + package_dir: The directory holding the package's pyproject.toml. + + Returns: + The build table, empty when the package declares none. + """ + with (package_dir / "pyproject.toml").open("rb") as f: + pyproject = tomllib.load(f) + return pyproject.get("tool", {}).get("hatch", {}).get("build", {}) + + +def generates_stubs(build: Mapping[str, Any]) -> bool: + """Report whether a package is expected to ship generated .pyi stubs. + + Either signal is enough on its own. A package declaring the stub-generating + build hook must ship stubs, and so must one declaring a ``*.pyi`` build + artifact — the root ``reflex`` package generates its stubs from a custom + hook and is only recognizable by the latter. Reading the hook independently + of the artifact declaration is what catches a package that generates stubs + but never declares them: ``*.pyi`` is gitignored, so hatchling leaves the + generated files out of the artifact unless they are listed. + + Hatch accepts ``hooks`` and ``artifacts`` both at the top of the build table + and under an individual target, and every scope is read. + + Args: + build: The package's ``[tool.hatch.build]`` table. + + Returns: + Whether the package's built artifacts must contain .pyi files. + """ + return any( + PYI_HOOK in scope.get("hooks", {}) + or any(pattern.endswith(".pyi") for pattern in scope.get("artifacts", ())) + for scope in (build, *build.get("targets", {}).values()) + ) + + +def stub_count(artifact: Path) -> int: + """Count the .pyi files inside a built artifact. + + Args: + artifact: A wheel (``*.whl``) or an sdist (``*.tar.gz``). + + Returns: + The number of .pyi members the artifact contains. + """ + if artifact.name.endswith(".whl"): + with zipfile.ZipFile(artifact) as wheel: + return sum(name.endswith(".pyi") for name in wheel.namelist()) + with tarfile.open(artifact, "r:gz") as sdist: + return sum(name.endswith(".pyi") for name in sdist.getnames()) + + +def main() -> int: + """Check every artifact of a stub-generating package for its stubs. + + Returns: + 0 when the package generates no stubs or every artifact carries them, + 1 otherwise. + """ + package = os.environ["PACKAGE"] + build_dir = Path(os.environ["BUILD_DIR"]) + dist_dir = Path(os.environ["DIST_DIR"]) + + if not generates_stubs(build_table(build_dir)): + print(f"{package} does not generate .pyi stubs, nothing to verify") + return 0 + + # Every artifact, not just the first match: a build spread over a matrix + # uploads several wheels and a leg that lost its stubs is as broken as a + # single build that produced none. + artifacts = sorted( + path + for path in dist_dir.glob("*") + if path.is_file() and path.name.endswith(ARTIFACT_SUFFIXES) + ) + if not artifacts: + print( + f"Error: {package} generates .pyi stubs but {dist_dir} holds no " + "wheel or sdist to check" + ) + return 1 + + missing: list[str] = [] + for artifact in artifacts: + count = stub_count(artifact) + if count: + print(f"✓ {artifact.name}: {count} .pyi files") + else: + missing.append(artifact.name) + print(f"✗ {artifact.name}: no .pyi files") + + if missing: + print( + f"Error: {package} generates .pyi stubs but they are missing from " + f"{', '.join(missing)}. A version can only be uploaded once, so a " + "release with no type information is not recoverable — this stops " + "the release." + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/units/test_verify_pyi.py b/tests/units/test_verify_pyi.py new file mode 100644 index 00000000000..98ca66acd64 --- /dev/null +++ b/tests/units/test_verify_pyi.py @@ -0,0 +1,243 @@ +"""Unit tests for scripts/verify_pyi.py (the published-artifact .pyi stub check).""" + +import io +import sys +import tarfile +import zipfile +from collections.abc import Sequence +from pathlib import Path + +import pytest + +if sys.version_info < (3, 11): + pytest.importorskip("tomli", reason="verify_pyi requires tomli on Python < 3.11") + +from scripts import verify_pyi + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def write_pyproject(package_dir: Path, build_table: str) -> Path: + """Write a pyproject declaring the given [tool.hatch.build] body. + + Args: + package_dir: Directory to write the pyproject into. + build_table: The body of the package's [tool.hatch.build] table. + + Returns: + The package directory. + """ + package_dir.mkdir(parents=True, exist_ok=True) + (package_dir / "pyproject.toml").write_text( + f'[project]\nname = "pkg"\nversion = "1.0"\n\n{build_table}\n' + ) + return package_dir + + +def make_wheel(path: Path, names: Sequence[str]) -> Path: + """Write a wheel containing empty members with the given names. + + Args: + path: Where to write the wheel. + names: Archive member names. + + Returns: + The wheel path. + """ + with zipfile.ZipFile(path, "w") as wheel: + for name in names: + wheel.writestr(name, "") + return path + + +def make_sdist(path: Path, names: Sequence[str]) -> Path: + """Write an sdist containing empty members with the given names. + + Args: + path: Where to write the sdist. + names: Archive member names. + + Returns: + The sdist path. + """ + with tarfile.open(path, "w:gz") as sdist: + for name in names: + sdist.addfile(tarfile.TarInfo(name), io.BytesIO(b"")) + return path + + +def run_main(monkeypatch: pytest.MonkeyPatch, build_dir: Path, dist_dir: Path) -> int: + """Run verify_pyi.main with the hook's environment set. + + Args: + monkeypatch: The fixture used to set the environment. + build_dir: The package directory holding pyproject.toml. + dist_dir: The directory holding the built artifacts. + + Returns: + The exit code. + """ + monkeypatch.setenv("PACKAGE", "pkg") + monkeypatch.setenv("BUILD_DIR", str(build_dir)) + monkeypatch.setenv("DIST_DIR", str(dist_dir)) + return verify_pyi.main() + + +def test_generates_stubs_from_hook(): + assert verify_pyi.generates_stubs({"hooks": {"reflex-pyi": {}}}) + + +def test_generates_stubs_from_artifacts(): + assert verify_pyi.generates_stubs({"targets": {"wheel": {"artifacts": ["*.pyi"]}}}) + + +def test_generates_stubs_from_sdist_artifacts_only(): + assert verify_pyi.generates_stubs({ + "targets": {"sdist": {"artifacts": ["*.json", "*.pyi"]}} + }) + + +def test_generates_stubs_from_top_level_artifacts(): + assert verify_pyi.generates_stubs({"artifacts": ["/reflex/**/*.pyi"]}) + + +def test_generates_stubs_from_target_hook(): + assert verify_pyi.generates_stubs({ + "targets": {"wheel": {"hooks": {"reflex-pyi": {}}}} + }) + + +def test_generates_stubs_ignores_unrelated_hooks_and_artifacts(): + assert not verify_pyi.generates_stubs({ + "hooks": {"custom": {"path": "scripts/other_build.py"}}, + "targets": {"wheel": {"artifacts": ["*.json"]}}, + }) + + +def test_generates_stubs_without_build_table(): + assert not verify_pyi.generates_stubs({}) + + +def test_build_table_reads_nested_table(tmp_path: Path): + package = write_pyproject( + tmp_path, "[tool.hatch.build]\ntargets.wheel.artifacts = ['*.pyi']" + ) + assert verify_pyi.build_table(package) == { + "targets": {"wheel": {"artifacts": ["*.pyi"]}} + } + + +def test_build_table_without_hatch_config(tmp_path: Path): + assert verify_pyi.build_table(write_pyproject(tmp_path, "")) == {} + + +def test_stub_count_wheel(tmp_path: Path): + wheel = make_wheel( + tmp_path / "pkg-1.0-py3-none-any.whl", + ["pkg/__init__.py", "pkg/a.pyi", "pkg/b.pyi"], + ) + assert verify_pyi.stub_count(wheel) == 2 + + +def test_stub_count_wheel_without_stubs(tmp_path: Path): + wheel = make_wheel(tmp_path / "pkg-1.0-py3-none-any.whl", ["pkg/__init__.py"]) + assert verify_pyi.stub_count(wheel) == 0 + + +def test_stub_count_sdist(tmp_path: Path): + sdist = make_sdist( + tmp_path / "pkg-1.0.tar.gz", ["pkg-1.0/pkg/__init__.py", "pkg-1.0/pkg/a.pyi"] + ) + assert verify_pyi.stub_count(sdist) == 1 + + +def test_main_skips_package_that_generates_no_stubs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + build_dir = write_pyproject(tmp_path / "pkg", "") + dist_dir = tmp_path / "dist" + dist_dir.mkdir() + make_wheel(dist_dir / "pkg-1.0-py3-none-any.whl", ["pkg/__init__.py"]) + assert run_main(monkeypatch, build_dir, dist_dir) == 0 + + +def test_main_accepts_stubs_in_every_artifact( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + build_dir = write_pyproject( + tmp_path / "pkg", "[tool.hatch.build.hooks.reflex-pyi]\ndependencies = []" + ) + dist_dir = tmp_path / "dist" + dist_dir.mkdir() + make_wheel(dist_dir / "pkg-1.0-py3-none-any.whl", ["pkg/a.pyi"]) + make_sdist(dist_dir / "pkg-1.0.tar.gz", ["pkg-1.0/pkg/a.pyi"]) + assert run_main(monkeypatch, build_dir, dist_dir) == 0 + + +def test_main_rejects_artifact_without_stubs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + build_dir = write_pyproject( + tmp_path / "pkg", "[tool.hatch.build.hooks.reflex-pyi]\ndependencies = []" + ) + dist_dir = tmp_path / "dist" + dist_dir.mkdir() + make_wheel(dist_dir / "pkg-1.0-py3-none-any.whl", ["pkg/__init__.py"]) + assert run_main(monkeypatch, build_dir, dist_dir) == 1 + assert "pkg-1.0-py3-none-any.whl" in capsys.readouterr().out + + +def test_main_rejects_a_single_artifact_missing_stubs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + """One bad leg of a build matrix must fail even when its siblings are fine.""" + build_dir = write_pyproject( + tmp_path / "pkg", "[tool.hatch.build.hooks.reflex-pyi]\ndependencies = []" + ) + dist_dir = tmp_path / "dist" + dist_dir.mkdir() + make_wheel(dist_dir / "pkg-1.0-cp313-macosx.whl", ["pkg/a.pyi"]) + make_wheel(dist_dir / "pkg-1.0-cp313-linux.whl", ["pkg/__init__.py"]) + assert run_main(monkeypatch, build_dir, dist_dir) == 1 + out = capsys.readouterr().out + assert "pkg-1.0-cp313-linux.whl" in out.split("Error:")[1] + + +def test_main_rejects_empty_dist_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + build_dir = write_pyproject( + tmp_path / "pkg", "[tool.hatch.build.hooks.reflex-pyi]\ndependencies = []" + ) + dist_dir = tmp_path / "dist" + dist_dir.mkdir() + assert run_main(monkeypatch, build_dir, dist_dir) == 1 + + +def test_main_ignores_non_distribution_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Only wheels and sdists are artifacts; uv build drops a .gitignore into dist/.""" + build_dir = write_pyproject( + tmp_path / "pkg", "[tool.hatch.build.hooks.reflex-pyi]\ndependencies = []" + ) + dist_dir = tmp_path / "dist" + dist_dir.mkdir() + (dist_dir / ".gitignore").write_text("*\n") + make_wheel(dist_dir / "pkg-1.0-py3-none-any.whl", ["pkg/a.pyi"]) + assert run_main(monkeypatch, build_dir, dist_dir) == 0 + + +@pytest.mark.parametrize( + "package_dir", + [REPO_ROOT, *sorted((REPO_ROOT / "packages").glob("reflex-components-*"))], + ids=lambda path: path.name or "reflex", +) +def test_stub_generating_packages_are_detected(package_dir: Path): + """The real reflex and component packages must all be recognised as stub shippers.""" + assert verify_pyi.generates_stubs(verify_pyi.build_table(package_dir)) + + +@pytest.mark.parametrize("package", ["reflex-base", "reflex-release", "reflex-docgen"]) +def test_packages_without_stubs_are_not_detected(package: str): + assert not verify_pyi.generates_stubs( + verify_pyi.build_table(REPO_ROOT / "packages" / package) + )