diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3b4ff5b..b965a86 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -162,11 +162,44 @@ jobs: - run: uv pip install --python .minimal/bin/python dist/*.whl "beartype==0.23.0rc0" - run: .minimal/bin/python -I tools/smoke_minimal.py + installed: + name: Installed consumers (Python ${{ matrix.python }}) + needs: distributions + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.14"] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.ref || github.sha }} + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v7 + with: + version: "0.11.17" + enable-cache: true + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: candidate-distributions + path: dist/ + - run: >- + uv run --locked --only-group dev python tools/check_installed.py + dist/*.whl dist/*.tar.gz --python "${{ matrix.python }}" + required: name: Required validation if: always() needs: - [quality, runtime, checkers, compatibility, docs, distributions, minimal] + [ + quality, + runtime, + checkers, + compatibility, + docs, + distributions, + minimal, + installed, + ] runs-on: ubuntu-latest steps: - name: Require every validation job to succeed diff --git a/CHANGELOG.md b/CHANGELOG.md index d78a80a..9eb66b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project follows ## [Unreleased] +- Require runtime and four-checker consumer tests from normally installed + sdist-derived wheels outside the checkout on Python 3.10 and 3.14. + - Share candidate validation across PRs, pushes and nightly runs, with explicit CPU backends, four checkers, complete hooks and a required final gate. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa09c43..9da5afa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,3 +92,16 @@ Use `uv run --locked prek run -a` for the normal hooks and Pre-push checks use the locked four-checker harness and runtime suite. The required CI gate accepts only successful completion of every required job. CuPy GPU validation remains a separate hardware-backed requirement. + +To validate the installed artifact after building the sdist and its wheel, run: + +```bash +uv run --locked python tools/check_installed.py \ + dist/*.whl dist/*.tar.gz --python 3.10 +``` + +Repeat with `--python 3.14`. The command installs locked dependencies and the +wheel normally in a temporary environment, then runs copied runtime and checker +fixtures with no package source directory. It reports artifact hashes and +installed module origins. `--installed-package` is an explicit pytest mode for +this consumer check; ordinary source validation continues to include `src`. diff --git a/plans/2026-09-08-installed-consumers.md b/plans/2026-09-08-installed-consumers.md new file mode 100644 index 0000000..6d68566 --- /dev/null +++ b/plans/2026-09-08-installed-consumers.md @@ -0,0 +1,88 @@ +# Prove the normally installed candidate artifact + + +Maintain this ExecPlan according to `PLANS.md`. This focused artifact-consumer PR is stacked on CI PR #23 and implements the remaining installation proof in M8. + +## Purpose / Big Picture + + +The package users install must pass the same runtime and static consumer contracts as the checkout. Build the wheel from the source archive, normally install it with exact beartype 0.23.0rc0, and run copied downstream tests outside every source checkout. Keep the resulting hashes and module origins traceable to the candidate commit. + +## Progress + + +- [x] (2026-09-08) Created isolated branch/worktree from the combined CI candidate. +- [x] (2026-09-08) Opened draft PR #25 and inspected archive/test configuration. +- [x] (2026-09-08) Added explicit --installed-package mode; normal source targets remain unchanged. +- [x] (2026-09-08) Added normal wheel installation driver with copied archive fixtures, dependency lock and site-packages origin checks. +- [x] (2026-09-08) Initial isolated wheel runs each passed all 1,074 tests with five documented platform/CuPy skips. +- [x] (2026-09-08) Added required endpoint jobs consuming the same candidate-distributions artifact. +- [x] (2026-09-08) Final archive-owned-lock runs passed 1,074 tests with five documented skips at both endpoints; full hooks and actionlint pass. +- [x] (2026-09-08) Every required hosted job passed at 73b3ec9; PR #25 is ready for user review. +- [ ] Record hashes, origins and results; obtain user validation before merge. + +## Surprises & Discoveries + + +Source tests currently assume a src directory in the checker target list. The archive already ships tests and configuration, but copied installed-package checks must omit that source target explicitly. The CI work exposed a hard-coded pyright venv override that could conceal the actual environment; its fix is present in this base. + +## Decision Log + + +Decision: Add an explicit pytest installed-package option rather than infer mode from a missing source directory. Rationale: an accidental missing source tree must not silently reduce normal validation. Date: 2026-09-08. + +Decision: Copy only downstream tests/configuration from the source archive into a temporary consumer directory, leaving package source absent. Rationale: source imports can mask incomplete wheels and missing typing markers. Install the wheel normally, with declared dependencies and exact beartype; never bypass resolution with --no-deps. + +Decision: Reuse the candidate-distributions artifact from the shared build job. Rationale: consumer validation and subsequent publication must refer to the same bytes. Keep publication changes in a separate PR. + +## Outcomes & Retrospective + + +The final normally installed wheel passes all 1,074 runtime/checker tests on Python 3.10.20 and 3.14.5. Both environments import bearshape from site-packages with exact beartype 0.23.0rc0, no editable project and no copied src directory. Five skips are CuPy absence and platform long-double distinctions. Hosted validation and user merge review remain. + +## Context and Orientation + + +Worktree `/Users/ale/Code/bearshape-worktrees/installed-consumers`, branch `codex/installed-consumers`, base CI `f396e72`. `tools/check_distribution.py` checks license, metadata, package bytes and source-test inclusion. `tests/test_typecheck.py` runs four positive/negative batches. `tests/conftest.py` owns pytest filtering; `pytest.toml` configures the shared runner defaults. The shared workflow builds one sdist-derived wheel and makes it available as candidate-distributions. + +## Plan of Work + + +Add --installed-package to pytest and make only the positive source/checker batch omit src in that explicit mode. Runtime and negative fixtures stay unchanged. Build and inspect archives, then create a fresh normal virtual environment for the selected interpreter. Export locked runtime/backend/checker/test dependencies without the editable project, install those dependencies and the actual wheel with exact beartype. + +Extract downstream tests and required configuration into a temporary directory without src. Verify bearshape imports from the new environment's site-packages, no editable project is installed, and exact versions/hashes are reported. Run the full CPU runtime suite plus every checker against copied fixtures. Fail on missing tools, unexpected diagnostics or source origins. Keep optional CuPy skips explicit; separate GPU evidence remains required. + +Wire endpoint consumer jobs to the existing distribution artifact and include them in the required gate. Save concise results and hashes. Run hooks and workflow lint, then inspect hosted results. + +## Concrete Steps + + +From this worktree build sdist and then its wheel, using tools/check_distribution.py before installing. Use a separate temporary consumer root and normal uv pip installation. Run copied tests with: + + python -I -m pytest tests/ --installed-package -n 4 + +Record actual checker imports and ensure their interpreter paths point to the consumer environment. Use the maintained command from CI at both endpoints. + +## Validation and Acceptance + + +The consumer directory contains no src/bearshape. Normal wheel resolution permits exact beartype rc0. Runtime tests and all four checker batches pass under Python 3.10 and 3.14, with only documented absent-CuPy/platform skips. Report artifact SHA256 and installed module paths. The final CI gate requires both endpoint results and no rebuild occurs between artifact validation stages. + +## Idempotence and Recovery + + +Use disposable directories and local virtual environments only. Do not modify system Python, main, release tags or ownership. Keep feature PRs separate and require user validation before merge. + +## Artifacts and Notes + + +Evidence goes under `/Users/ale/Code/bearshape-implementation-2026-09-08/evidence/installed-*`. The final local wheel SHA256 is ef8d38266da7150efa80191589b7ba0310007a6ae2dbb82d30d4bfe4e0ed49cf; source archive SHA256 is e738d7ab34d74d7ea221f1db3aef291e37baf7ad38c5c9dfa7276735dc93c23a. Consumer logs are installed-final-3.10.log and installed-final-3.14.log; each reports its temporary site-packages origin. Hosted builds record their own hashes because each archive is tied to its source state. These results complement, but do not replace, exact GPU runtime tests or the unresolved native-union contract decision. + +## Interfaces and Dependencies + + +No public runtime API or dependency change is planned. The pytest option is for downstream artifact validation. Use the existing locked backend/checker/test groups and standard-library archive/process tools for orchestration. + +Revision note — 2026-09-08: Validated the final driver using the archive's own lockfile and required both installed-consumer jobs in CI. + +Revision note — 2026-09-08: Recorded the completed hosted validation and retained user merge approval as the only remaining review action for this focused scope. Program-level release decisions remain in the roadmap and handoff report. diff --git a/tests/conftest.py b/tests/conftest.py index 530abfe..0183be6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,14 @@ } +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--installed-package", + action="store_true", + help="Check copied consumers against an installed wheel without source targets", + ) + + def pytest_collection_modifyitems( config: pytest.Config, items: list[pytest.Item] ) -> None: diff --git a/tests/test_typecheck.py b/tests/test_typecheck.py index c5b12ee..1a2c289 100644 --- a/tests/test_typecheck.py +++ b/tests/test_typecheck.py @@ -138,8 +138,11 @@ def _diagnostics(tool: str, output: str) -> Counter[tuple[str, int, str]]: @pytest.mark.typecheck @pytest.mark.parametrize("tool", SELECTED_CHECKERS) -def test_valid_consumers_and_source(tool: str) -> None: - result = _run(tool, ["src", "tests/typing"]) +def test_valid_consumers_and_source(tool: str, pytestconfig: pytest.Config) -> None: + targets = ["tests/typing"] + if not pytestconfig.getoption("--installed-package"): + targets.insert(0, "src") + result = _run(tool, targets) assert result.returncode == 0, ( f"{tool} rejected valid code on Python {PYTHON_TARGET}:\n{result.stdout}\n{result.stderr}" ) diff --git a/tools/check_distribution.py b/tools/check_distribution.py index ed74956..5b9f8cf 100644 --- a/tools/check_distribution.py +++ b/tools/check_distribution.py @@ -25,12 +25,15 @@ "ty.toml", "ruff.toml", "tools/check_distribution.py", + "tools/check_installed.py", + "tools/validate_runtime.py", "tools/validate_tox_env.py", "tests/conftest.py", "tests/test_numpy.py", "tests/test_tree.py", "tests/test_typecheck.py", "tests/typing/check_imports.py", + "tests/typing_negative/invalid_calls.py", } diff --git a/tools/check_installed.py b/tools/check_installed.py new file mode 100644 index 0000000..50c96e1 --- /dev/null +++ b/tools/check_installed.py @@ -0,0 +1,154 @@ +"""Normally install a candidate wheel and test consumers outside the checkout.""" + +# Command driver: argument lists only, never a shell. +# ruff: noqa: S404, S603 + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path + +from check_distribution import check_distribution + +_CONFIGS = {"pyproject.toml", "pytest.toml", "ty.toml", "uv.lock"} +_ORIGIN_CHECK = """ +import importlib.metadata +import json +import pathlib +import sys +import bearshape +import beartype +origin = pathlib.Path(bearshape.__file__).resolve() +assert origin.is_relative_to(pathlib.Path(sys.prefix)), origin +assert beartype.__version__ == '0.23.0rc0', beartype.__version__ +metadata = importlib.metadata.distribution('bearshape') +direct = json.loads(metadata.read_text('direct_url.json') or '{}') +assert not direct.get('dir_info', {}).get('editable'), direct +print(json.dumps({'python': sys.version, 'bearshape': bearshape.__version__, + 'beartype': beartype.__version__, 'module': str(origin)}, indent=2)) +""" + + +def _copy_consumers(sdist: Path, destination: Path) -> None: + prefix = sdist.name.removesuffix(".tar.gz") + "/" + with tarfile.open(sdist, "r:gz") as archive: + for member in archive.getmembers(): + name = member.name.removeprefix(prefix) + if not member.isfile() or not ( + name.startswith("tests/") + or name in _CONFIGS + or name == "tools/validate_runtime.py" + ): + continue + target = (destination / name).resolve() + if not target.is_relative_to(destination): + message = f"Invalid consumer archive path: {member.name}" + raise ValueError(message) + stream = archive.extractfile(member) + if stream is None: + message = f"Cannot read consumer file: {member.name}" + raise ValueError(message) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(stream.read()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wheel", type=Path) + parser.add_argument("sdist", type=Path) + parser.add_argument("--python", required=True, help="Consumer Python version") + args = parser.parse_args() + uv = shutil.which("uv") + if uv is None: + parser.error("uv is required to create the isolated consumer environment") + wheel, sdist = args.wheel.resolve(), args.sdist.resolve() + evidence = check_distribution(wheel, sdist) + json.dump(evidence, sys.stdout, indent=2) + sys.stdout.write("\n") + sys.stdout.flush() + environment = dict(os.environ, UV_TORCH_BACKEND="cpu") + for key in ("PYTHONPATH", "PYTHONHOME", "TOX_ENV_NAME"): + environment.pop(key, None) + + with tempfile.TemporaryDirectory(prefix="bearshape-installed-") as temporary: + consumer = Path(temporary).resolve() + _copy_consumers(sdist, consumer) + requirements = consumer / "requirements.txt" + subprocess.run( + [ + uv, + "export", + "--locked", + "--no-default-groups", + "--group", + "optional", + "--group", + "static", + "--group", + "test", + "--no-emit-project", + "--output-file", + str(requirements), + ], + check=True, + cwd=consumer, + env=environment, + stdout=subprocess.DEVNULL, + ) + venv = consumer / ".venv" + subprocess.run( + [uv, "venv", "--python", args.python, str(venv)], + check=True, + cwd=consumer, + env=environment, + ) + python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + subprocess.run( + [uv, "pip", "sync", "--python", str(python), str(requirements)], + check=True, + cwd=consumer, + env=environment, + ) + subprocess.run( + [ + uv, + "pip", + "install", + "--python", + str(python), + str(wheel), + "beartype==0.23.0rc0", + ], + check=True, + cwd=consumer, + env=environment, + ) + subprocess.run( + [str(python), "-I", "-c", _ORIGIN_CHECK], + check=True, + cwd=consumer, + env=environment, + ) + subprocess.run( + [str(python), "-I", "tools/validate_runtime.py", "cpu"], + check=True, + cwd=consumer, + env=environment, + ) + subprocess.run( + [str(python), "-I", "-m", "pytest", "tests/", "--installed-package", "-n", "4"], + check=True, + cwd=consumer, + env=environment, + ) + + +if __name__ == "__main__": + main()