Skip to content

Commit 8a8d06d

Browse files
authored
Merge pull request #212 from thisisthepy/feat/tp-gate-artifact
Build: Publish the TypedPython gate bundle beside the KSP processor (#20)
2 parents c039b7e + 83e2d1a commit 8a8d06d

4 files changed

Lines changed: 207 additions & 0 deletions

File tree

‎python-multiplatform-ksp/build.gradle.kts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,30 @@ tasks.test {
2121
// python-multiplatform-gradle-plugin's `generateCoordinates` task, from this same group/artifactId/
2222
// version) names an artifact that never gets published anywhere -- every consumer outside this repo
2323
// resolves the plugin id fine and then fails on the processor dependency itself.
24+
// The TypedPython gate, published beside the processor as `<this>:typedpython-gate@zip` (#20,
25+
// toolchain#90): the typedpython wheel under its real file name plus typedpython-gate.json (wheel
26+
// sha256, pyrefly version and the sha256 of every pyrefly wheel), so toolchain's typedpythonCheck
27+
// installs it with uv --require-hashes and needs no -Ptypedpython.wheelDir. gate_bundle.py builds it.
28+
val typedpythonGateBundle by tasks.registering(Exec::class) {
29+
group = "build"
30+
description = "Builds the TypedPython gate bundle (wheel + manifest) published with this module."
31+
val out = layout.buildDirectory.file("typedpython-gate/typedpython-gate.zip")
32+
inputs.files(fileTree("src/main/python") { exclude("**/__pycache__/**") }).withPropertyName("gateSources")
33+
inputs.files("pyproject.toml", "typedpython-gate.lock.json", "gate_bundle.py").withPropertyName("gateBuild")
34+
outputs.file(out)
35+
executable = "python3"
36+
args("gate_bundle.py", "--out", out.get().asFile.absolutePath)
37+
}
38+
2439
publishing {
2540
publications {
2641
create<MavenPublication>("maven") {
2742
from(components["java"])
43+
artifact(typedpythonGateBundle.map { it.outputs.files.singleFile }) {
44+
classifier = "typedpython-gate"
45+
extension = "zip"
46+
builtBy(typedpythonGateBundle)
47+
}
2848
}
2949
}
3050
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Build the TypedPython gate bundle that ships beside the KSP processor (#20, toolchain#90).
2+
3+
python3 gate_bundle.py --out <path>/typedpython-gate.zip
4+
5+
The zip holds, at its root, the `typedpython` wheel under its real file name (so uv's
6+
`--find-links <unzipped dir>` finds it) and `typedpython-gate.json`:
7+
8+
{"format": 1,
9+
"typedpython": {"version": ..., "wheel": ..., "sha256": ...},
10+
"pyrefly": {"version": ..., "sha256": {<wheel file name>: <sha256>, ...}}}
11+
12+
The pyrefly hashes come from `typedpython-gate.lock.json` (PyPI's published digests for every pyrefly
13+
wheel of the pinned version), so toolchain can install pyrefly from PyPI with `--require-hashes`.
14+
The wheel is built with `uv build`; zip entries carry a fixed timestamp so the bundle depends only
15+
on its inputs.
16+
"""
17+
from __future__ import annotations
18+
19+
import argparse
20+
import hashlib
21+
import json
22+
import shutil
23+
import subprocess
24+
import sys
25+
import tempfile
26+
import tomllib
27+
import zipfile
28+
from pathlib import Path
29+
30+
MODULE = Path(__file__).resolve().parent
31+
LOCK = MODULE / "typedpython-gate.lock.json"
32+
MANIFEST = "typedpython-gate.json"
33+
FIXED_TIME = (1980, 1, 1, 0, 0, 0)
34+
35+
36+
def build(out: Path) -> Path:
37+
project = tomllib.loads((MODULE / "pyproject.toml").read_text())["project"]
38+
version = project["version"]
39+
lock = json.loads(LOCK.read_text())["pyrefly"]
40+
if f"pyrefly=={lock['version']}" not in project["dependencies"]:
41+
sys.exit(f"{LOCK.name} pins pyrefly {lock['version']}, pyproject.toml depends on {project['dependencies']}")
42+
out.parent.mkdir(parents=True, exist_ok=True)
43+
with tempfile.TemporaryDirectory(dir=out.parent) as tmp:
44+
run = subprocess.run(["uv", "build", "--wheel", "--out-dir", tmp, str(MODULE)],
45+
capture_output=True, text=True)
46+
if run.returncode != 0:
47+
sys.exit(f"uv build failed ({run.returncode}):\n{run.stdout}{run.stderr}")
48+
wheel = Path(tmp) / f"typedpython-{version}-py3-none-any.whl"
49+
if not wheel.is_file():
50+
sys.exit(f"expected {wheel.name}, uv built {sorted(p.name for p in Path(tmp).iterdir())}")
51+
data = wheel.read_bytes()
52+
manifest = {
53+
"format": 1,
54+
"typedpython": {"version": version, "wheel": wheel.name, "sha256": hashlib.sha256(data).hexdigest()},
55+
"pyrefly": {"version": lock["version"], "sha256": lock["sha256"]},
56+
}
57+
partial = out.with_suffix(".partial")
58+
with zipfile.ZipFile(partial, "w", zipfile.ZIP_DEFLATED) as z:
59+
for name, payload in ((wheel.name, data),
60+
(MANIFEST, (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode())):
61+
info = zipfile.ZipInfo(name, FIXED_TIME)
62+
info.compress_type = zipfile.ZIP_DEFLATED
63+
info.external_attr = 0o644 << 16
64+
z.writestr(info, payload)
65+
shutil.move(partial, out)
66+
return out
67+
68+
69+
def main() -> None:
70+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
71+
parser.add_argument("--out", required=True, type=Path)
72+
print(build(parser.parse_args().out.resolve()))
73+
74+
75+
if __name__ == "__main__":
76+
main()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""The TypedPython gate bundle published beside the KSP processor (#20, toolchain#90).
2+
3+
toolchain's typedpythonCheck resolves `python-multiplatform-ksp:<version>:typedpython-gate@zip`,
4+
unzips it and installs the gate from it with uv `--require-hashes`, so the zip must hold the wheel
5+
under its real file name and a manifest with every hash the install needs.
6+
"""
7+
import hashlib
8+
import json
9+
import subprocess
10+
import sys
11+
import tomllib
12+
import zipfile
13+
from pathlib import Path
14+
15+
import pytest
16+
17+
MODULE = Path(__file__).resolve().parents[3] # python-multiplatform-ksp
18+
SCRIPT = MODULE / "gate_bundle.py"
19+
LOCK = MODULE / "typedpython-gate.lock.json"
20+
21+
22+
@pytest.fixture(scope="module")
23+
def bundle(tmp_path_factory):
24+
out = tmp_path_factory.mktemp("gate") / "typedpython-gate.zip"
25+
run = subprocess.run([sys.executable, str(SCRIPT), "--out", str(out)], capture_output=True, text=True)
26+
assert run.returncode == 0, run.stdout + run.stderr
27+
return out
28+
29+
30+
def _pyproject():
31+
return tomllib.loads((MODULE / "pyproject.toml").read_text())
32+
33+
34+
def test_zip_holds_the_wheel_under_its_real_name_and_a_manifest(bundle):
35+
names = zipfile.ZipFile(bundle).namelist()
36+
version = _pyproject()["project"]["version"]
37+
assert sorted(names) == sorted([f"typedpython-{version}-py3-none-any.whl", "typedpython-gate.json"])
38+
39+
40+
def test_manifest_matches_the_wheel_and_the_pins(bundle):
41+
z = zipfile.ZipFile(bundle)
42+
m = json.loads(z.read("typedpython-gate.json"))
43+
project = _pyproject()["project"]
44+
assert m["format"] == 1
45+
assert m["typedpython"]["version"] == project["version"]
46+
wheel = m["typedpython"]["wheel"]
47+
assert m["typedpython"]["sha256"] == hashlib.sha256(z.read(wheel)).hexdigest()
48+
# the pyrefly pin is the one the wheel itself depends on, with a hash for every published wheel
49+
assert f"pyrefly=={m['pyrefly']['version']}" in project["dependencies"]
50+
assert m["pyrefly"]["sha256"] == json.loads(LOCK.read_text())["pyrefly"]["sha256"]
51+
assert any("macosx_11_0_arm64" in f for f in m["pyrefly"]["sha256"])
52+
assert all(f.endswith(".whl") for f in m["pyrefly"]["sha256"])
53+
54+
55+
def test_lock_pins_the_same_pyrefly_as_the_package():
56+
lock = json.loads(LOCK.read_text())["pyrefly"]
57+
assert f"pyrefly=={lock['version']}" in _pyproject()["project"]["dependencies"]
58+
assert all(f.startswith(f"pyrefly-{lock['version']}-") for f in lock["sha256"])
59+
60+
61+
def test_the_wheel_installs_with_hashes_and_checks_code(bundle, tmp_path):
62+
# the install toolchain does, minus the network: typedpython from the unzipped dir with its hash
63+
# (--no-deps here: pyrefly comes from PyPI there, from this test's own interpreter here)
64+
d = tmp_path / "unzipped"
65+
zipfile.ZipFile(bundle).extractall(d)
66+
m = json.loads((d / "typedpython-gate.json").read_text())
67+
req = tmp_path / "req.txt"
68+
req.write_text(f"typedpython=={m['typedpython']['version']} --hash=sha256:{m['typedpython']['sha256']}\n")
69+
target = tmp_path / "site"
70+
run = subprocess.run(["uv", "pip", "install", "--python", sys.executable, "--target", str(target),
71+
"--no-index", "--find-links", str(d), "--no-deps", "--require-hashes",
72+
"-r", str(req)], capture_output=True, text=True)
73+
assert run.returncode == 0, run.stdout + run.stderr
74+
assert (target / "typedpython" / "__main__.py").is_file()
75+
clean = tmp_path / "clean.py"
76+
clean.write_text("def f(x: int) -> int:\n return x + 1\n")
77+
env = {"PYTHONPATH": str(target), "PATH": __import__("os").environ["PATH"],
78+
"TMPDIR": str(tmp_path)}
79+
check = subprocess.run([sys.executable, "-m", "typedpython", "check", str(clean)],
80+
capture_output=True, text=True, env=env)
81+
assert check.returncode == 0, check.stdout + check.stderr
82+
83+
84+
def test_the_wheel_carries_the_c_runtime_and_the_builtin_stub(bundle):
85+
# compiling needs tp_runtime.h; checking needs the `compiled` builtin stub
86+
import io
87+
z = zipfile.ZipFile(bundle)
88+
wheel = json.loads(z.read("typedpython-gate.json"))["typedpython"]["wheel"]
89+
names = zipfile.ZipFile(io.BytesIO(z.read(wheel))).namelist()
90+
for needed in ("typedpython/runtime/tp_runtime.h", "typedpython/builtin_stubs/__builtins__.pyi",
91+
"typedpython_builtins.pth", "typedpython/__main__.py"):
92+
assert needed in names, needed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"pyrefly": {
3+
"sha256": {
4+
"pyrefly-1.3.2-py3-none-macosx_10_12_x86_64.whl": "626dbaee16ae1169a10a4ba3293583b5ab0320102f35efaad9e03457a2fc646c",
5+
"pyrefly-1.3.2-py3-none-macosx_11_0_arm64.whl": "2d5fc704dbf0e990dc4f15cb1ead64f1a325ae27383a50c76cfcaf0387f926bb",
6+
"pyrefly-1.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "89dbe8a33e735fa96023c33ce5dfd81af9b0bdd2ee771e8d69b2ebffc7e0fbc7",
7+
"pyrefly-1.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl": "3ec0c31715d48bff279f548b85a93b5bc43d2c7bd1492a8cd703a9185cb089e5",
8+
"pyrefly-1.3.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "3b3de468bed691e40dc6271284c8dcc05e649d7ed45280ef3d07332741efe34c",
9+
"pyrefly-1.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "cb94365fe5f854cb4fe82f600c4e2453160d328610b12981004851ee0b6e8aeb",
10+
"pyrefly-1.3.2-py3-none-musllinux_1_2_aarch64.whl": "4f730d2e515ac25827fc68f343a7aa02b1072e0062df6a430346a4ffdfb5db2f",
11+
"pyrefly-1.3.2-py3-none-musllinux_1_2_x86_64.whl": "60f440e1626bd5cc4a41ee90cecba80796ed01ebd6c15d9f933efb8d07ffe2b8",
12+
"pyrefly-1.3.2-py3-none-win32.whl": "b31684189de44a25a5bf00bdf3ad16be44a7b14654702d6018bce1043357202c",
13+
"pyrefly-1.3.2-py3-none-win_amd64.whl": "693960cdb247d1f76b9e09b3f2554dae20034f7d442ccd4e9b3e8962a3622363",
14+
"pyrefly-1.3.2-py3-none-win_arm64.whl": "f7424b21331670522980ff13e08bd143659879a4d92f4832282c7d7c41a9b5d6"
15+
},
16+
"source": "https://pypi.org/pypi/pyrefly/1.3.2/json",
17+
"version": "1.3.2"
18+
}
19+
}

0 commit comments

Comments
 (0)