From 199c5784e3bce535beb0d6876992cc7d0569799e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 22:04:56 +0000 Subject: [PATCH 1/3] Check .pyi stubs in every stub-shipping package's release artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit post_build.sh only checked the reflex wheel, so a component package whose build silently produced no stubs could be published with no type information — a release that is not recoverable, since a version can only be uploaded to PyPI once. The check now covers every package whose build generates stubs: reflex itself and the 14 packages driven by the hatch-reflex-pyi hook. Which packages those are is read out of the package's own pyproject rather than listed anywhere, so one that starts or stops generating stubs is covered without touching the check. Either signal is enough: declaring the hook, or declaring *.pyi as a build artifact (how the root package's custom hook is recognized). Reading them independently 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 listed. Also widens what is inspected. Sdists are checked alongside wheels, since a stubless sdist rebuilds without stubs wherever reflex-base is unavailable. And every artifact must carry stubs rather than just one: the old `unzip -l "$DIST_DIR"/*.whl` passed a second wheel to unzip as a member pattern instead of listing it, so a build matrix leg that lost its stubs went unnoticed. Verified against real builds of all 15 packages (wheel and sdist each), a package that generates no stubs, and a stub-stripped wheel. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NwKbDTae52hrg5HY63whHb --- .github/scripts/publish/post_build.sh | 21 ++- pyproject.toml | 1 + scripts/verify_pyi.py | 149 ++++++++++++++++ tests/units/test_verify_pyi.py | 233 ++++++++++++++++++++++++++ 4 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 scripts/verify_pyi.py create mode 100644 tests/units/test_verify_pyi.py diff --git a/.github/scripts/publish/post_build.sh b/.github/scripts/publish/post_build.sh index 989a63da816..bb719f08e8d 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-project python \ + "$(dirname "$0")/../../../scripts/verify_pyi.py" diff --git a/pyproject.toml b/pyproject.toml index 5871df45bda..41f1d96428b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -265,6 +265,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..294a2ff22c6 --- /dev/null +++ b/scripts/verify_pyi.py @@ -0,0 +1,149 @@ +"""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. +""" + +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 ``*.pyi`` as a 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. + + Args: + build: The package's ``[tool.hatch.build]`` table. + + Returns: + Whether the package's built artifacts must contain .pyi files. + """ + if PYI_HOOK in build.get("hooks", {}): + return True + return any( + pattern.endswith(".pyi") + for target in build.get("targets", {}).values() + for pattern in target.get("artifacts", ()) + ) + + +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..f3035d1df33 --- /dev/null +++ b/tests/units/test_verify_pyi.py @@ -0,0 +1,233 @@ +"""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_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) + ) From 9980b981da8fdba7e07969ce5bd42e81694d0c82 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:11:37 +0000 Subject: [PATCH 2/3] Provision the tomllib backport for the .pyi verifier hook The 3.10 fallback could not work as written: the hook ran the verifier with `uv run --no-project`, which resolves none of the project's dependencies, so `import tomli` raised ModuleNotFoundError on any interpreter below 3.11 rather than falling back. Declare the backport as inline script metadata and run the verifier with `uv run --script`, which provisions it from the script's own dependencies. Matches scripts/check_min_deps.py, which carries the same block for the same reason. `--no-config` keeps the ephemeral resolution clear of the workspace's uv settings. The publish job runs 3.14, where tomllib is stdlib and the block resolves to no dependencies at all, so the release path installs nothing new. Verified by running the hook against a real build on 3.10 (reproduced the ModuleNotFoundError first), on 3.14 and with no UV_PYTHON set, across the pass, skip and stub-stripped-wheel paths. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NwKbDTae52hrg5HY63whHb --- .github/scripts/publish/post_build.sh | 2 +- scripts/verify_pyi.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/scripts/publish/post_build.sh b/.github/scripts/publish/post_build.sh index bb719f08e8d..cba278f20f7 100755 --- a/.github/scripts/publish/post_build.sh +++ b/.github/scripts/publish/post_build.sh @@ -15,5 +15,5 @@ set -euo pipefail # # Located relative to this hook rather than to the working directory, which is # what DIST_DIR is relative to. -exec uv run --no-project python \ +exec uv run --no-config --script \ "$(dirname "$0")/../../../scripts/verify_pyi.py" diff --git a/scripts/verify_pyi.py b/scripts/verify_pyi.py index 294a2ff22c6..5e74546e643 100644 --- a/scripts/verify_pyi.py +++ b/scripts/verify_pyi.py @@ -16,6 +16,16 @@ 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 From 6f2f3ec219ef1108e5d8ad0863e1bc69174340d5 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 1 Sep 2026 21:11:25 +0500 Subject: [PATCH 3/3] Read pyi build declarations from every hatch scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hatch accepts `hooks` and `artifacts` both at the top of `[tool.hatch.build]` and under an individual target. verify_pyi looked for the hook only at the top level and for the artifacts only per target, so the root reflex package — which declares `artifacts = ["/reflex/**/*.pyi"]` at the top level — was classified as generating no stubs, and the publish hook skipped its artifacts entirely. Verified against real builds: the reflex wheel and sdist now report their three stubs and pass, and both fail the release when the stubs are stripped. --- scripts/verify_pyi.py | 13 +++++++------ tests/units/test_verify_pyi.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/scripts/verify_pyi.py b/scripts/verify_pyi.py index 5e74546e643..3348bea215e 100644 --- a/scripts/verify_pyi.py +++ b/scripts/verify_pyi.py @@ -67,25 +67,26 @@ 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 ``*.pyi`` as a build + 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. """ - if PYI_HOOK in build.get("hooks", {}): - return True return any( - pattern.endswith(".pyi") - for target in build.get("targets", {}).values() - for pattern in target.get("artifacts", ()) + 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()) ) diff --git a/tests/units/test_verify_pyi.py b/tests/units/test_verify_pyi.py index f3035d1df33..98ca66acd64 100644 --- a/tests/units/test_verify_pyi.py +++ b/tests/units/test_verify_pyi.py @@ -97,6 +97,16 @@ def test_generates_stubs_from_sdist_artifacts_only(): }) +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"}},