Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions python-multiplatform-ksp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<this>: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<MavenPublication>("maven") {
from(components["java"])
artifact(typedpythonGateBundle.map { it.outputs.files.singleFile }) {
classifier = "typedpython-gate"
extension = "zip"
builtBy(typedpythonGateBundle)
}
}
}
}
76 changes: 76 additions & 0 deletions python-multiplatform-ksp/gate_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Build the TypedPython gate bundle that ships beside the KSP processor (#20, toolchain#90).

python3 gate_bundle.py --out <path>/typedpython-gate.zip

The zip holds, at its root, the `typedpython` wheel under its real file name (so uv's
`--find-links <unzipped dir>` finds it) and `typedpython-gate.json`:

{"format": 1,
"typedpython": {"version": ..., "wheel": ..., "sha256": ...},
"pyrefly": {"version": ..., "sha256": {<wheel file name>: <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()
92 changes: 92 additions & 0 deletions python-multiplatform-ksp/src/test/python/test_gate_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""The TypedPython gate bundle published beside the KSP processor (#20, toolchain#90).

toolchain's typedpythonCheck resolves `python-multiplatform-ksp:<version>: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
19 changes: 19 additions & 0 deletions python-multiplatform-ksp/typedpython-gate.lock.json
Original file line number Diff line number Diff line change
@@ -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"
}
}