diff --git a/src/jl2py/__init__.py b/src/jl2py/__init__.py index 10348b5..db89647 100644 --- a/src/jl2py/__init__.py +++ b/src/jl2py/__init__.py @@ -12,6 +12,9 @@ from jl2py.exceptions import JuliaError from jl2py.debug import debug from jl2py.types import JuliaAny, LayoutFlags, register_wrapper, unregister_wrapper +# Bundle fetcher — stdlib-only, independent of the native lib, so it imports and +# runs (e.g. to pre-fetch a bundle) without a built libjl2py / a live Julia. +from jl2py import bundles __version__ = "0.3.0" __all__ = [ @@ -20,4 +23,5 @@ "JuliaError", "LayoutFlags", "register_wrapper", "unregister_wrapper", "debug", + "bundles", ] diff --git a/src/jl2py/bundles.py b/src/jl2py/bundles.py new file mode 100644 index 0000000..7e70160 --- /dev/null +++ b/src/jl2py/bundles.py @@ -0,0 +1,413 @@ +""" +jl2py.bundles — fetch, verify, cache, and unpack Julia package-image bundles. + +A *bundle* is a directory of precompiled Julia package images (produced by a +producer's ``build_image.jl`` and published as a ``.tar.zst`` tarball, indexed by +an R2 ``manifest.json``). jl2py owns the **end-state contract** — cache layout, +checksum verification against the manifest, ``bundle.json`` validation, and an +atomic unpack — while the **transport is injected** by the caller as a ``fetch`` +callback. That keeps this module ``ZERO-DEP`` (stdlib only) and auth-agnostic: +jl2py never learns about R2, awscli, boto3, or presigned URLs, and the artifact +store can stay closed/credentialed. + +``ensure(...)`` is the single entry point:: + + from jl2py import bundles + path = bundles.ensure("piccolissimo-pkgimage", fetch=my_fetch) + +The manifest shape (an R2 object at ``/manifest.json``) is:: + + { + "product": "piccolissimo-pkgimage", + "latest_version": "0.4.0", + "builds": [ + {"product": "piccolissimo-pkgimage", "version": "0.4.0", + "platform": "x86_64-linux-gnu", "julia_version": "1.12.6", + "filename": "piccolissimo-pkgimage-0.4.0-x86_64-linux-gnu-julia1.12.6.tar.zst", + "sha256": "e3b0c4...", "size_bytes": 734003200}, + ... + ] + } + +and the tarballs sit *flat* under the same prefix, keyed by ``filename``. + +The ``fetch`` callback +---------------------- + +``fetch(relative_key: str, dest: pathlib.Path) -> None`` must download the object +named ``relative_key`` (``"manifest.json"`` or a tarball ``filename``, both +relative to the store's product prefix) and write it to ``dest``. jl2py handles +everything else. Some transports the caller can supply (none imported here): + +*R2 via awscli* (subprocess; credentials from the environment):: + + import os, subprocess + def fetch(relative_key, dest): + endpoint = f"https://{os.environ['R2_ACCOUNT_ID']}.r2.cloudflarestorage.com" + key = f"piccolissimo/pkgimage/{relative_key}" + subprocess.run( + ["aws", "s3", "cp", "--endpoint-url", endpoint, + f"s3://harmoniqs-binaries/{key}", str(dest)], + check=True, + env={**os.environ, + "AWS_ACCESS_KEY_ID": os.environ["R2_ACCESS_KEY_ID"], + "AWS_SECRET_ACCESS_KEY": os.environ["R2_SECRET_ACCESS_KEY"]}, + ) + +*R2 via boto3* (S3-compatible client):: + + import boto3 + s3 = boto3.client("s3", endpoint_url=endpoint, + aws_access_key_id=..., aws_secret_access_key=...) + def fetch(relative_key, dest): + s3.download_file("harmoniqs-binaries", + f"piccolissimo/pkgimage/{relative_key}", str(dest)) + +*Presigned / plain HTTPS URLs* (stdlib ``urllib``):: + + import urllib.request + def fetch(relative_key, dest): + url = presign(f"piccolissimo/pkgimage/{relative_key}") # your signer + urllib.request.urlretrieve(url, dest) + +Escape hatches (no ``fetch`` needed) +------------------------------------ + +* ``$JL2PY_BUNDLE_PATH`` — if set, ``ensure`` returns that directory directly + (after validating its ``bundle.json``). This is the air-gapped / hand-placed + flow. +* A fully pinned bundle (``version`` and ``julia_version`` given) that is already + in the cache is returned without any fetch — not even the manifest is pulled. + +Unpacking ``.tar.zst`` requires the ``zstd`` CLI on ``PATH`` (recommended) or the +optional ``zstandard`` Python package; a clear error is raised if neither exists. +""" + +import hashlib +import json +import os +import pathlib +import platform as platform_mod +import shutil +import subprocess +import sys +import tarfile +import tempfile + +__all__ = ["ensure"] + +_DEFAULT_CACHE = pathlib.Path("~/.cache/jl2py/bundles") + + +# ── public API ───────────────────────────────────────────────────────────── + + +def ensure( + name, + *, + version=None, + platform=None, + julia_version=None, + fetch=None, + manifest=None, + cache_dir=None, +): + """Resolve, fetch, verify, and unpack a package-image bundle; return its dir. + + ``name`` is the product discriminator (e.g. ``"piccolissimo-pkgimage"``) and + doubles as the cache-dir prefix. The bundle is cached at + ``/---julia/`` and reused + thereafter (idempotent). + + Parameters + ---------- + version: + Bundle version. Default: the manifest's ``latest_version``. + platform: + Julia platform triple (e.g. ``"x86_64-linux-gnu"``). Default: the current + host's triple, derived from ``platform.machine()`` / ``sys.platform``. + julia_version: + Exact Julia patch the bundle was built for (package images are + patch-exact). Default: the highest ``julia_version`` available for the + resolved ``(version, platform)``. + fetch: + ``fetch(relative_key, dest)`` transport callback (see the module + docstring). Required unless the bundle is already resolvable without a + download (``$JL2PY_BUNDLE_PATH`` or a fully pinned cache hit). + manifest: + A manifest ``dict`` or a path to a ``manifest.json`` file. If ``None``, + ``fetch("manifest.json", ...)`` is used to pull it. + cache_dir: + Root cache directory. Default: ``$JL2PY_BUNDLE_CACHE`` else + ``~/.cache/jl2py/bundles``. + + Raises + ------ + RuntimeError + On sha256 mismatch (nothing is left in the cache), no matching manifest + build, a missing/corrupt ``bundle.json``, a ``julia_version`` mismatch in + the unpacked bundle, or a missing zstd toolchain. + TypeError + If a download is required but no ``fetch`` callback was given. + """ + # 0. Air-gapped escape hatch — a hand-placed bundle wins over everything. + override = os.environ.get("JL2PY_BUNDLE_PATH") + if override: + return _validate_bundle(pathlib.Path(override).expanduser(), julia_version) + + plat = platform or _current_platform_triple() + cache = pathlib.Path( + cache_dir or os.environ.get("JL2PY_BUNDLE_CACHE") or _DEFAULT_CACHE + ).expanduser() + + # 1. Fully pinned + already cached -> return without touching the network at + # all (not even the manifest). Keeps repeat/offline runs free. + if version is not None and julia_version is not None: + final = cache / _bundle_dirname(name, version, plat, julia_version) + if final.is_dir(): + return _validate_bundle(final, julia_version) + + if fetch is None: + raise TypeError( + "ensure() needs a fetch callback to download the bundle " + "(or a cached bundle / $JL2PY_BUNDLE_PATH); none was given" + ) + + with tempfile.TemporaryDirectory() as scratch: + man = _load_manifest(manifest, fetch, pathlib.Path(scratch)) + rver, rjulia, entry = _select_build(man, name, version, plat, julia_version) + + final = cache / _bundle_dirname(name, rver, plat, rjulia) + if final.is_dir(): + # Resolved (via manifest) to an already-cached bundle -> skip the tarball. + return _validate_bundle(final, rjulia) + + cache.mkdir(parents=True, exist_ok=True) + # Work under the cache root so the atomic rename stays on one filesystem. + workdir = pathlib.Path(tempfile.mkdtemp(dir=cache, prefix=".fetch-")) + try: + filename = entry["filename"] + tarball = workdir / filename + fetch(filename, tarball) + if not tarball.is_file() or tarball.stat().st_size == 0: + raise RuntimeError( + f"fetch callback did not produce {filename!r} at {tarball}" + ) + + got = _sha256_file(tarball) + want = str(entry.get("sha256") or "").strip().lower() + if not want: + raise RuntimeError( + f"manifest build {filename!r} carries no sha256 — refusing to " + "trust an unverifiable bundle" + ) + if got != want: + raise RuntimeError( + f"sha256 mismatch for {filename!r}: manifest says {want}, " + f"downloaded {got}. Refusing to unpack a corrupt/tampered bundle." + ) + + unpack = workdir / "unpacked" + _unpack_tar_zst(tarball, unpack) + _validate_bundle(unpack, rjulia) + + try: + os.replace(unpack, final) # atomic on a single filesystem + except OSError: + # Lost a race with a concurrent ensure() — accept its result. + if final.is_dir(): + return _validate_bundle(final, rjulia) + raise + return final + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +# ── manifest resolution ────────────────────────────────────────────────────── + + +def _load_manifest(manifest, fetch, scratch): + if isinstance(manifest, dict): + return manifest + if manifest is not None: # a path to a manifest.json + with open(manifest) as f: + return json.load(f) + dest = scratch / "manifest.json" + fetch("manifest.json", dest) + if not dest.is_file(): + raise RuntimeError("fetch callback did not produce manifest.json") + with open(dest) as f: + return json.load(f) + + +def _select_build(manifest, name, version, platform, julia_version): + """Pick the manifest build entry for (name, version, platform, julia_version). + + Returns ``(resolved_version, resolved_julia_version, entry)``. + """ + builds = manifest.get("builds") or [] + ver = version or manifest.get("latest_version") + if ver is None: + raise RuntimeError( + "no version given and the manifest has no latest_version to fall back on" + ) + + def matches(b): + # Manifests are per-product, but filter defensively when product is present. + if name is not None and b.get("product") not in (None, name): + return False + if b.get("version") != ver or b.get("platform") != platform: + return False + return julia_version is None or b.get("julia_version") == julia_version + + cands = [b for b in builds if matches(b)] + if not cands: + avail = sorted( + {(b.get("version"), b.get("platform"), b.get("julia_version")) for b in builds} + ) + raise RuntimeError( + f"no bundle for product={name} version={ver} platform={platform} " + f"julia={julia_version or 'any'}; manifest offers {avail}" + ) + + if julia_version is not None: + entry, rjulia = cands[0], julia_version + else: + # Highest available Julia patch for this (version, platform). + entry = max(cands, key=lambda b: _version_key(b.get("julia_version", ""))) + rjulia = entry.get("julia_version") + + if not entry.get("filename"): + raise RuntimeError(f"manifest build {entry!r} has no filename") + return ver, rjulia, entry + + +# ── verification, unpack, validation ───────────────────────────────────────── + + +def _sha256_file(path, _chunk=1 << 20): + h = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(_chunk), b""): + h.update(block) + return h.hexdigest() + + +def _unpack_tar_zst(tarball, dest): + """Decompress + extract a ``.tar.zst`` into ``dest`` (created if absent). + + Prefers the ``zstd`` CLI (zero-dep); falls back to the optional ``zstandard`` + module; raises a clear error if neither is available. + """ + dest = pathlib.Path(dest) + dest.mkdir(parents=True, exist_ok=True) + tar_path = pathlib.Path(str(tarball) + ".tar") + + zstd = shutil.which("zstd") or shutil.which("unzstd") + if zstd: + try: + subprocess.run( + [zstd, "-d", "-f", "-o", str(tar_path), str(tarball)], + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + raise RuntimeError( + f"zstd failed to decompress {tarball}: " + f"{(e.stderr or b'').decode(errors='replace').strip()}" + ) from e + try: + _extract_tar(tar_path, dest) + finally: + tar_path.unlink(missing_ok=True) + return + + try: + import zstandard # optional; not a hard dependency + except ImportError as e: + raise RuntimeError( + "unpacking a .tar.zst bundle needs the `zstd` CLI on PATH (install it via " + "your package manager, e.g. `apt install zstd` / `brew install zstd`) or " + "the optional `zstandard` Python package (`pip install zstandard`); " + "neither was found." + ) from e + + dctx = zstandard.ZstdDecompressor() + with open(tarball, "rb") as ifh, open(tar_path, "wb") as ofh: + dctx.copy_stream(ifh, ofh) + try: + _extract_tar(tar_path, dest) + finally: + tar_path.unlink(missing_ok=True) + + +def _extract_tar(tar_path, dest): + """Extract a (seekable) tar into ``dest``, refusing paths that escape it.""" + dest_real = os.path.realpath(dest) + with tarfile.open(tar_path, "r:") as tf: + for m in tf.getmembers(): + target = os.path.realpath(os.path.join(dest, m.name)) + if target != dest_real and not target.startswith(dest_real + os.sep): + raise RuntimeError( + f"refusing to extract unsafe path {m.name!r} from bundle tarball" + ) + try: + tf.extractall(dest, filter="data") # py>=3.12 + except TypeError: + tf.extractall(dest) + + +def _validate_bundle(path, julia_version): + """Assert ``path`` is a bundle dir with a parseable, patch-matching bundle.json.""" + path = pathlib.Path(path) + if not path.is_dir(): + raise FileNotFoundError(f"bundle dir not found: {path}") + meta = path / "bundle.json" + if not meta.is_file(): + raise FileNotFoundError(f"{path} is not a jl2py bundle (no bundle.json)") + try: + with open(meta) as f: + bj = json.load(f) + except (OSError, ValueError) as e: + raise RuntimeError(f"corrupt or unreadable bundle.json in {path}: {e}") from e + if not isinstance(bj, dict): + raise RuntimeError(f"bundle.json in {path} is not a JSON object") + if julia_version is not None and bj.get("julia_version") != julia_version: + raise RuntimeError( + f"bundle at {path} was built for Julia {bj.get('julia_version')!r}, but " + f"Julia {julia_version!r} was requested; package images are patch-exact." + ) + return path + + +# ── platform / version helpers ─────────────────────────────────────────────── + + +def _bundle_dirname(name, version, platform, julia_version): + return f"{name}-{version}-{platform}-julia{julia_version}" + + +def _current_platform_triple(): + """Map the host to a Julia platform triple, e.g. ``x86_64-linux-gnu``.""" + machine = platform_mod.machine().lower() + arch = { + "amd64": "x86_64", + "x86_64": "x86_64", + "arm64": "aarch64", + "aarch64": "aarch64", + }.get(machine, machine) + if sys.platform.startswith("linux"): + return f"{arch}-linux-gnu" + if sys.platform == "darwin": + return f"{arch}-apple-darwin" + if sys.platform.startswith("win") or sys.platform == "cygwin": + return f"{arch}-w64-mingw32" + return f"{arch}-{sys.platform}" + + +def _version_key(v): + """Sort key that orders dotted versions numerically (1.12.10 > 1.12.6).""" + key = [] + for tok in str(v).split("."): + key.append((0, int(tok)) if tok.isdigit() else (1, tok)) + return tuple(key) diff --git a/tests/test_bundles.py b/tests/test_bundles.py new file mode 100644 index 0000000..697bc2d --- /dev/null +++ b/tests/test_bundles.py @@ -0,0 +1,258 @@ +""" +Tests for jl2py.bundles — the zero-dep bundle fetcher. + +These need NO Julia and NO native libjl2py: the fetcher is pure stdlib and the +transport is a fake ``fetch`` callback serving fixture files from a tmpdir. The +few tests that exercise the real unpack build a tiny ``.tar.zst`` with the ``zstd`` +CLI and skip (with a clear reason) if it is absent — Ubuntu CI has zstd. +""" + +import hashlib +import json +import os +import shutil +import subprocess +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from jl2py import bundles # noqa: E402 + +_HAS_ZSTD = shutil.which("zstd") is not None +needs_zstd = pytest.mark.skipif(not _HAS_ZSTD, reason="zstd CLI required to build/unpack tar.zst") + +PRODUCT = "piccolissimo-pkgimage" +PLATFORM = "x86_64-linux-gnu" + + +# ── fixtures / builders ─────────────────────────────────────────────────────── + + +def _write_bundle_dir(root, *, julia_version, product=PRODUCT, version="0.4.0", + platform=PLATFORM, bundle_json=True, bad_json=False): + """Create a bundle-dir layout (bundle.json + load_bundle.jl) under `root`.""" + root.mkdir(parents=True, exist_ok=True) + (root / "load_bundle.jl").write_text("module BundleLoader end\n") + if bundle_json: + meta = root / "bundle.json" + if bad_json: + meta.write_text("{ this is : not json ,,,") + else: + meta.write_text(json.dumps({ + "product": product, "version": version, "platform": platform, + "julia_version": julia_version, + })) + return root + + +def _make_tarball(tmp_path, *, julia_version, product=PRODUCT, version="0.4.0", platform=PLATFORM): + """Build a flat-top-level `.tar.zst` bundle; return (path, filename, sha256).""" + src = tmp_path / "src_bundle" + _write_bundle_dir(src, julia_version=julia_version, product=product, + version=version, platform=platform) + filename = f"{product}-{version}-{platform}-julia{julia_version}.tar.zst" + tarball = tmp_path / filename + subprocess.run( + ["tar", "--use-compress-program=zstd", "-cf", str(tarball), "-C", str(src), "."], + check=True, + ) + sha = hashlib.sha256(tarball.read_bytes()).hexdigest() + return tarball, filename, sha + + +def _manifest(builds, latest_version="0.4.0", product=PRODUCT): + return {"product": product, "latest_version": latest_version, "builds": builds} + + +def _build_entry(filename, sha, *, version="0.4.0", platform=PLATFORM, + julia_version, product=PRODUCT, size=123): + return {"product": product, "version": version, "platform": platform, + "julia_version": julia_version, "filename": filename, + "sha256": sha, "size_bytes": size} + + +def _make_fetch(files): + """A fake transport serving `relative_key -> bytes` and recording call keys.""" + calls = [] + + def fetch(relative_key, dest): + calls.append(relative_key) + if relative_key not in files: + raise KeyError(f"fake fetch has no object {relative_key!r}") + data = files[relative_key] + dest.write_bytes(data if isinstance(data, (bytes, bytearray)) else data.read_bytes()) + + fetch.calls = calls + return fetch + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + monkeypatch.delenv("JL2PY_BUNDLE_PATH", raising=False) + monkeypatch.delenv("JL2PY_BUNDLE_CACHE", raising=False) + + +# ── env-override escape hatch ───────────────────────────────────────────────── + + +def test_env_override_returns_path(tmp_path, monkeypatch): + bundle = _write_bundle_dir(tmp_path / "airgap", julia_version="1.12.6") + monkeypatch.setenv("JL2PY_BUNDLE_PATH", str(bundle)) + called = [] + got = bundles.ensure(PRODUCT, julia_version="1.12.6", + fetch=lambda k, d: called.append(k)) + assert os.path.realpath(got) == os.path.realpath(bundle) + assert called == [] # never touches the transport + + +def test_env_override_julia_mismatch_errors(tmp_path, monkeypatch): + bundle = _write_bundle_dir(tmp_path / "airgap", julia_version="1.12.5") + monkeypatch.setenv("JL2PY_BUNDLE_PATH", str(bundle)) + with pytest.raises(RuntimeError, match="patch-exact"): + bundles.ensure(PRODUCT, julia_version="1.12.6", fetch=lambda k, d: None) + + +def test_corrupted_bundle_json_rejected(tmp_path, monkeypatch): + bundle = _write_bundle_dir(tmp_path / "airgap", julia_version="1.12.6", bad_json=True) + monkeypatch.setenv("JL2PY_BUNDLE_PATH", str(bundle)) + with pytest.raises(RuntimeError, match="corrupt or unreadable bundle.json"): + bundles.ensure(PRODUCT, fetch=lambda k, d: None) + + +def test_missing_bundle_json_rejected(tmp_path, monkeypatch): + bundle = tmp_path / "airgap" + bundle.mkdir() + monkeypatch.setenv("JL2PY_BUNDLE_PATH", str(bundle)) + with pytest.raises(FileNotFoundError, match="no bundle.json"): + bundles.ensure(PRODUCT, fetch=lambda k, d: None) + + +# ── manifest resolution ─────────────────────────────────────────────────────── + + +def test_no_matching_build_errors(tmp_path): + man = _manifest([_build_entry("f.tar.zst", "aa", julia_version="1.12.6")]) + with pytest.raises(RuntimeError, match="no bundle for"): + bundles.ensure(PRODUCT, platform="aarch64-apple-darwin", + manifest=man, fetch=lambda k, d: None, + cache_dir=str(tmp_path / "cache")) + + +def test_fetch_none_without_pin_or_cache_raises(tmp_path): + with pytest.raises(TypeError, match="fetch callback"): + bundles.ensure(PRODUCT, cache_dir=str(tmp_path / "cache")) + + +@needs_zstd +def test_resolves_latest_version_and_highest_julia(tmp_path): + # Two julia patches for the latest version; expect the *highest* (1.12.10 > 1.12.6). + tb6, fn6, sha6 = _make_tarball(tmp_path, julia_version="1.12.6") + tb10, fn10, sha10 = _make_tarball(tmp_path, julia_version="1.12.10") + man = _manifest( + [ + _build_entry(fn6, sha6, julia_version="1.12.6"), + _build_entry(fn10, sha10, julia_version="1.12.10"), + _build_entry("older.tar.zst", "zz", version="0.3.0", julia_version="1.12.6"), + ], + latest_version="0.4.0", + ) + fetch = _make_fetch({fn6: tb6, fn10: tb10}) + got = bundles.ensure(PRODUCT, platform=PLATFORM, manifest=man, fetch=fetch, + cache_dir=str(tmp_path / "cache")) + assert got.name == f"{PRODUCT}-0.4.0-{PLATFORM}-julia1.12.10" + assert (got / "bundle.json").is_file() + assert json.loads((got / "bundle.json").read_text())["julia_version"] == "1.12.10" + assert fetch.calls == [fn10] # only the winning tarball was downloaded + + +@needs_zstd +def test_manifest_fetched_when_not_given(tmp_path): + tb, fn, sha = _make_tarball(tmp_path, julia_version="1.12.6") + man = _manifest([_build_entry(fn, sha, julia_version="1.12.6")]) + fetch = _make_fetch({"manifest.json": json.dumps(man).encode(), fn: tb}) + got = bundles.ensure(PRODUCT, platform=PLATFORM, fetch=fetch, + cache_dir=str(tmp_path / "cache")) + assert fetch.calls == ["manifest.json", fn] # manifest first, then the tarball + assert (got / "bundle.json").is_file() + + +@needs_zstd +def test_manifest_from_file_path(tmp_path): + tb, fn, sha = _make_tarball(tmp_path, julia_version="1.12.6") + man = _manifest([_build_entry(fn, sha, julia_version="1.12.6")]) + man_path = tmp_path / "manifest.json" + man_path.write_text(json.dumps(man)) + fetch = _make_fetch({fn: tb}) + got = bundles.ensure(PRODUCT, platform=PLATFORM, manifest=str(man_path), fetch=fetch, + cache_dir=str(tmp_path / "cache")) + assert fetch.calls == [fn] + assert got.is_dir() + + +# ── integrity + atomicity ───────────────────────────────────────────────────── + + +def test_sha256_mismatch_no_cache_pollution(tmp_path): + # The served bytes need not even be valid zstd — the sha guard fires first. + fn = f"{PRODUCT}-0.4.0-{PLATFORM}-julia1.12.6.tar.zst" + bogus = b"not the real bytes" + wrong_sha = hashlib.sha256(b"something else").hexdigest() + man = _manifest([_build_entry(fn, wrong_sha, julia_version="1.12.6")]) + fetch = _make_fetch({fn: bogus}) + cache = tmp_path / "cache" + with pytest.raises(RuntimeError, match="sha256 mismatch"): + bundles.ensure(PRODUCT, platform=PLATFORM, manifest=man, fetch=fetch, + cache_dir=str(cache)) + # No final dir, and no leftover .fetch-* work dirs. + leftovers = list(cache.iterdir()) if cache.is_dir() else [] + assert leftovers == [], f"cache polluted after failed fetch: {leftovers}" + + +@needs_zstd +def test_idempotent_second_call_skips_fetch(tmp_path): + tb, fn, sha = _make_tarball(tmp_path, julia_version="1.12.6") + man = _manifest([_build_entry(fn, sha, julia_version="1.12.6")]) + fetch = _make_fetch({fn: tb}) + cache = str(tmp_path / "cache") + a = bundles.ensure(PRODUCT, version="0.4.0", julia_version="1.12.6", platform=PLATFORM, + manifest=man, fetch=fetch, cache_dir=cache) + b = bundles.ensure(PRODUCT, version="0.4.0", julia_version="1.12.6", platform=PLATFORM, + manifest=man, fetch=fetch, cache_dir=cache) + assert a == b + assert fetch.calls == [fn] # second call is a pure cache hit — no new fetch + + +@needs_zstd +def test_unpacked_julia_mismatch_rejected(tmp_path): + # Manifest claims 1.12.6 but the tarball's bundle.json says 1.12.5 -> clean error. + tb, _fn, sha = _make_tarball(tmp_path, julia_version="1.12.5") + fn = f"{PRODUCT}-0.4.0-{PLATFORM}-julia1.12.6.tar.zst" # requested filename + man = _manifest([_build_entry(fn, sha, julia_version="1.12.6")]) + fetch = _make_fetch({fn: tb}) + with pytest.raises(RuntimeError, match="patch-exact"): + bundles.ensure(PRODUCT, platform=PLATFORM, manifest=man, fetch=fetch, + cache_dir=str(tmp_path / "cache")) + + +@needs_zstd +def test_cache_dir_from_env(tmp_path, monkeypatch): + tb, fn, sha = _make_tarball(tmp_path, julia_version="1.12.6") + man = _manifest([_build_entry(fn, sha, julia_version="1.12.6")]) + fetch = _make_fetch({fn: tb}) + cache = tmp_path / "envcache" + monkeypatch.setenv("JL2PY_BUNDLE_CACHE", str(cache)) + got = bundles.ensure(PRODUCT, platform=PLATFORM, manifest=man, fetch=fetch) + assert str(got).startswith(str(cache)) + + +# ── platform derivation ─────────────────────────────────────────────────────── + + +def test_current_platform_triple_shape(): + triple = bundles._current_platform_triple() + assert triple.count("-") >= 2 + arch, rest = triple.split("-", 1) + assert arch in ("x86_64", "aarch64") or arch # non-empty + assert any(tag in rest for tag in ("linux-gnu", "apple-darwin", "w64-mingw32")) or rest