diff --git a/python-multiplatform-ksp/build.gradle.kts b/python-multiplatform-ksp/build.gradle.kts index a171a847..67b3f22a 100644 --- a/python-multiplatform-ksp/build.gradle.kts +++ b/python-multiplatform-ksp/build.gradle.kts @@ -21,10 +21,30 @@ tasks.test { // python-multiplatform-gradle-plugin's `generateCoordinates` task, from this same group/artifactId/ // version) names an artifact that never gets published anywhere -- every consumer outside this repo // resolves the plugin id fine and then fails on the processor dependency itself. +// The TypedPython gate, published beside the processor as `:typedpython-gate@zip` (#20, +// toolchain#90): the typedpython wheel under its real file name plus typedpython-gate.json (wheel +// sha256, pyrefly version and the sha256 of every pyrefly wheel), so toolchain's typedpythonCheck +// installs it with uv --require-hashes and needs no -Ptypedpython.wheelDir. gate_bundle.py builds it. +val typedpythonGateBundle by tasks.registering(Exec::class) { + group = "build" + description = "Builds the TypedPython gate bundle (wheel + manifest) published with this module." + val out = layout.buildDirectory.file("typedpython-gate/typedpython-gate.zip") + inputs.files(fileTree("src/main/python") { exclude("**/__pycache__/**") }).withPropertyName("gateSources") + inputs.files("pyproject.toml", "typedpython-gate.lock.json", "gate_bundle.py").withPropertyName("gateBuild") + outputs.file(out) + executable = "python3" + args("gate_bundle.py", "--out", out.get().asFile.absolutePath) +} + publishing { publications { create("maven") { from(components["java"]) + artifact(typedpythonGateBundle.map { it.outputs.files.singleFile }) { + classifier = "typedpython-gate" + extension = "zip" + builtBy(typedpythonGateBundle) + } } } } diff --git a/python-multiplatform-ksp/gate_bundle.py b/python-multiplatform-ksp/gate_bundle.py new file mode 100644 index 00000000..7f28bd5e --- /dev/null +++ b/python-multiplatform-ksp/gate_bundle.py @@ -0,0 +1,76 @@ +"""Build the TypedPython gate bundle that ships beside the KSP processor (#20, toolchain#90). + + python3 gate_bundle.py --out /typedpython-gate.zip + +The zip holds, at its root, the `typedpython` wheel under its real file name (so uv's +`--find-links ` finds it) and `typedpython-gate.json`: + + {"format": 1, + "typedpython": {"version": ..., "wheel": ..., "sha256": ...}, + "pyrefly": {"version": ..., "sha256": {: , ...}}} + +The pyrefly hashes come from `typedpython-gate.lock.json` (PyPI's published digests for every pyrefly +wheel of the pinned version), so toolchain can install pyrefly from PyPI with `--require-hashes`. +The wheel is built with `uv build`; zip entries carry a fixed timestamp so the bundle depends only +on its inputs. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import subprocess +import sys +import tempfile +import tomllib +import zipfile +from pathlib import Path + +MODULE = Path(__file__).resolve().parent +LOCK = MODULE / "typedpython-gate.lock.json" +MANIFEST = "typedpython-gate.json" +FIXED_TIME = (1980, 1, 1, 0, 0, 0) + + +def build(out: Path) -> Path: + project = tomllib.loads((MODULE / "pyproject.toml").read_text())["project"] + version = project["version"] + lock = json.loads(LOCK.read_text())["pyrefly"] + if f"pyrefly=={lock['version']}" not in project["dependencies"]: + sys.exit(f"{LOCK.name} pins pyrefly {lock['version']}, pyproject.toml depends on {project['dependencies']}") + out.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=out.parent) as tmp: + run = subprocess.run(["uv", "build", "--wheel", "--out-dir", tmp, str(MODULE)], + capture_output=True, text=True) + if run.returncode != 0: + sys.exit(f"uv build failed ({run.returncode}):\n{run.stdout}{run.stderr}") + wheel = Path(tmp) / f"typedpython-{version}-py3-none-any.whl" + if not wheel.is_file(): + sys.exit(f"expected {wheel.name}, uv built {sorted(p.name for p in Path(tmp).iterdir())}") + data = wheel.read_bytes() + manifest = { + "format": 1, + "typedpython": {"version": version, "wheel": wheel.name, "sha256": hashlib.sha256(data).hexdigest()}, + "pyrefly": {"version": lock["version"], "sha256": lock["sha256"]}, + } + partial = out.with_suffix(".partial") + with zipfile.ZipFile(partial, "w", zipfile.ZIP_DEFLATED) as z: + for name, payload in ((wheel.name, data), + (MANIFEST, (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode())): + info = zipfile.ZipInfo(name, FIXED_TIME) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + z.writestr(info, payload) + shutil.move(partial, out) + return out + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--out", required=True, type=Path) + print(build(parser.parse_args().out.resolve())) + + +if __name__ == "__main__": + main() diff --git a/python-multiplatform-ksp/src/test/python/test_gate_bundle.py b/python-multiplatform-ksp/src/test/python/test_gate_bundle.py new file mode 100644 index 00000000..985072bf --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_gate_bundle.py @@ -0,0 +1,92 @@ +"""The TypedPython gate bundle published beside the KSP processor (#20, toolchain#90). + +toolchain's typedpythonCheck resolves `python-multiplatform-ksp::typedpython-gate@zip`, +unzips it and installs the gate from it with uv `--require-hashes`, so the zip must hold the wheel +under its real file name and a manifest with every hash the install needs. +""" +import hashlib +import json +import subprocess +import sys +import tomllib +import zipfile +from pathlib import Path + +import pytest + +MODULE = Path(__file__).resolve().parents[3] # python-multiplatform-ksp +SCRIPT = MODULE / "gate_bundle.py" +LOCK = MODULE / "typedpython-gate.lock.json" + + +@pytest.fixture(scope="module") +def bundle(tmp_path_factory): + out = tmp_path_factory.mktemp("gate") / "typedpython-gate.zip" + run = subprocess.run([sys.executable, str(SCRIPT), "--out", str(out)], capture_output=True, text=True) + assert run.returncode == 0, run.stdout + run.stderr + return out + + +def _pyproject(): + return tomllib.loads((MODULE / "pyproject.toml").read_text()) + + +def test_zip_holds_the_wheel_under_its_real_name_and_a_manifest(bundle): + names = zipfile.ZipFile(bundle).namelist() + version = _pyproject()["project"]["version"] + assert sorted(names) == sorted([f"typedpython-{version}-py3-none-any.whl", "typedpython-gate.json"]) + + +def test_manifest_matches_the_wheel_and_the_pins(bundle): + z = zipfile.ZipFile(bundle) + m = json.loads(z.read("typedpython-gate.json")) + project = _pyproject()["project"] + assert m["format"] == 1 + assert m["typedpython"]["version"] == project["version"] + wheel = m["typedpython"]["wheel"] + assert m["typedpython"]["sha256"] == hashlib.sha256(z.read(wheel)).hexdigest() + # the pyrefly pin is the one the wheel itself depends on, with a hash for every published wheel + assert f"pyrefly=={m['pyrefly']['version']}" in project["dependencies"] + assert m["pyrefly"]["sha256"] == json.loads(LOCK.read_text())["pyrefly"]["sha256"] + assert any("macosx_11_0_arm64" in f for f in m["pyrefly"]["sha256"]) + assert all(f.endswith(".whl") for f in m["pyrefly"]["sha256"]) + + +def test_lock_pins_the_same_pyrefly_as_the_package(): + lock = json.loads(LOCK.read_text())["pyrefly"] + assert f"pyrefly=={lock['version']}" in _pyproject()["project"]["dependencies"] + assert all(f.startswith(f"pyrefly-{lock['version']}-") for f in lock["sha256"]) + + +def test_the_wheel_installs_with_hashes_and_checks_code(bundle, tmp_path): + # the install toolchain does, minus the network: typedpython from the unzipped dir with its hash + # (--no-deps here: pyrefly comes from PyPI there, from this test's own interpreter here) + d = tmp_path / "unzipped" + zipfile.ZipFile(bundle).extractall(d) + m = json.loads((d / "typedpython-gate.json").read_text()) + req = tmp_path / "req.txt" + req.write_text(f"typedpython=={m['typedpython']['version']} --hash=sha256:{m['typedpython']['sha256']}\n") + target = tmp_path / "site" + run = subprocess.run(["uv", "pip", "install", "--python", sys.executable, "--target", str(target), + "--no-index", "--find-links", str(d), "--no-deps", "--require-hashes", + "-r", str(req)], capture_output=True, text=True) + assert run.returncode == 0, run.stdout + run.stderr + assert (target / "typedpython" / "__main__.py").is_file() + clean = tmp_path / "clean.py" + clean.write_text("def f(x: int) -> int:\n return x + 1\n") + env = {"PYTHONPATH": str(target), "PATH": __import__("os").environ["PATH"], + "TMPDIR": str(tmp_path)} + check = subprocess.run([sys.executable, "-m", "typedpython", "check", str(clean)], + capture_output=True, text=True, env=env) + assert check.returncode == 0, check.stdout + check.stderr + + +def test_the_wheel_carries_the_c_runtime_and_the_builtin_stub(bundle): + # compiling needs tp_runtime.h; checking needs the `compiled` builtin stub + import io + z = zipfile.ZipFile(bundle) + wheel = json.loads(z.read("typedpython-gate.json"))["typedpython"]["wheel"] + names = zipfile.ZipFile(io.BytesIO(z.read(wheel))).namelist() + for needed in ("typedpython/runtime/tp_runtime.h", "typedpython/builtin_stubs/__builtins__.pyi", + "typedpython_builtins.pth", "typedpython/__main__.py"): + assert needed in names, needed diff --git a/python-multiplatform-ksp/typedpython-gate.lock.json b/python-multiplatform-ksp/typedpython-gate.lock.json new file mode 100644 index 00000000..01e3aa93 --- /dev/null +++ b/python-multiplatform-ksp/typedpython-gate.lock.json @@ -0,0 +1,19 @@ +{ + "pyrefly": { + "sha256": { + "pyrefly-1.3.2-py3-none-macosx_10_12_x86_64.whl": "626dbaee16ae1169a10a4ba3293583b5ab0320102f35efaad9e03457a2fc646c", + "pyrefly-1.3.2-py3-none-macosx_11_0_arm64.whl": "2d5fc704dbf0e990dc4f15cb1ead64f1a325ae27383a50c76cfcaf0387f926bb", + "pyrefly-1.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "89dbe8a33e735fa96023c33ce5dfd81af9b0bdd2ee771e8d69b2ebffc7e0fbc7", + "pyrefly-1.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl": "3ec0c31715d48bff279f548b85a93b5bc43d2c7bd1492a8cd703a9185cb089e5", + "pyrefly-1.3.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "3b3de468bed691e40dc6271284c8dcc05e649d7ed45280ef3d07332741efe34c", + "pyrefly-1.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "cb94365fe5f854cb4fe82f600c4e2453160d328610b12981004851ee0b6e8aeb", + "pyrefly-1.3.2-py3-none-musllinux_1_2_aarch64.whl": "4f730d2e515ac25827fc68f343a7aa02b1072e0062df6a430346a4ffdfb5db2f", + "pyrefly-1.3.2-py3-none-musllinux_1_2_x86_64.whl": "60f440e1626bd5cc4a41ee90cecba80796ed01ebd6c15d9f933efb8d07ffe2b8", + "pyrefly-1.3.2-py3-none-win32.whl": "b31684189de44a25a5bf00bdf3ad16be44a7b14654702d6018bce1043357202c", + "pyrefly-1.3.2-py3-none-win_amd64.whl": "693960cdb247d1f76b9e09b3f2554dae20034f7d442ccd4e9b3e8962a3622363", + "pyrefly-1.3.2-py3-none-win_arm64.whl": "f7424b21331670522980ff13e08bd143659879a4d92f4832282c7d7c41a9b5d6" + }, + "source": "https://pypi.org/pypi/pyrefly/1.3.2/json", + "version": "1.3.2" + } +}