plotpx 0.1.7

Pixel-focused plotting engine that renders magnitude grids, heatmaps, and spectra to RGBA buffers
Documentation
#!/usr/bin/env python3
"""Generate Artifacts.toml entries from release metadata.

This script consumes the metadata files uploaded by the matrix build job
(linux/macos/windows) and rewrites `julia/PlotPx/Artifacts.toml` with entries for
the supplied release tag. It mirrors Julia's artifact hashing by computing the
Git tree hash of the extracted tarball contents.
"""

from __future__ import annotations

import argparse
import hashlib
import os
import stat
import tarfile
import tempfile
from pathlib import Path
from typing import Dict, Iterable, List, Tuple

try:
    import tomllib  # Python 3.11+
except ModuleNotFoundError:  # pragma: no cover - defensive: fall back if older python
    import tomli as tomllib  # type: ignore

HEADER = "# Auto-generated artifact bindings. Update via `scripts/update_artifacts_from_metadata.py` or CI."


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--metadata-dir", required=True, help="Directory containing *.toml metadata files")
    parser.add_argument("--release-tag", required=True, help="Git tag for the release (e.g. v1.0.0)")
    parser.add_argument("--repo", required=True, help="GitHub repo in the form owner/name")
    parser.add_argument(
        "--artifacts-toml",
        default="julia/PlotPx/Artifacts.toml",
        help="Destination Artifacts.toml path to rewrite",
    )
    return parser.parse_args()


def list_metadata_files(metadata_dir: Path) -> List[Path]:
    files: List[Path] = []
    for root, _, filenames in os.walk(metadata_dir):
        for filename in filenames:
            if filename.endswith(".toml"):
                files.append(Path(root, filename))
    files.sort()
    if not files:
        raise FileNotFoundError(f"No metadata files found under {metadata_dir}")
    return files


def platform_fields(triple: str) -> Dict[str, str]:
    normalized = triple.strip().lower()
    if normalized in {"x86_64-unknown-linux-gnu", "x86_64-linux-gnu"}:
        return {"arch": "x86_64", "os": "linux", "libc": "glibc"}
    if normalized in {"aarch64-apple-darwin", "arm64-apple-darwin"}:
        return {"arch": "aarch64", "os": "macos"}
    if normalized in {"x86_64-pc-windows-msvc", "x86_64-w64-mingw32"}:
        return {"arch": "x86_64", "os": "windows", "call": "msvc"}
    raise ValueError(f"Unsupported platform triple: {triple}")


def compute_blob_hash(data: bytes, executable: bool) -> Tuple[bytes, bytes]:
    mode = b"100755" if executable else b"100644"
    header = b"blob " + str(len(data)).encode("ascii") + b"\0"
    digest = hashlib.sha1(header + data).digest()
    return mode, digest


def compute_tree_hash(root: Path) -> str:
    def walk_directory(path: Path) -> bytes:
        entries: List[bytes] = []
        for name in sorted(os.listdir(path)):
            full = path / name
            if os.path.islink(full):
                raise ValueError(f"Symlinks are not supported in artifacts: {full}")
            if full.is_dir():
                subtree = walk_directory(full)
                mode = b"040000"
                digest = hashlib.sha1(subtree).digest()
            else:
                with open(full, "rb") as fh:
                    data = fh.read()
                executable = bool(os.stat(full).st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH))
                mode, digest = compute_blob_hash(data, executable)
            entry = mode + b" " + name.encode("utf-8") + b"\0" + digest
            entries.append(entry)
        content = b"".join(entries)
        header = b"tree " + str(len(content)).encode("ascii") + b"\0"
        return header + content

    tree = walk_directory(root)
    return hashlib.sha1(tree).hexdigest()


def extract_tarball(tarball: Path, dest: Path) -> None:
    name = tarball.name.lower()
    mode = "r:gz" if name.endswith(".tar.gz") or name.endswith(".tgz") else "r"
    with tarfile.open(tarball, mode) as tf:
        tf.extractall(dest)


def artifact_entry(tarball: Path, triple: str, url: str, sha256: str) -> Dict[str, object]:
    with tempfile.TemporaryDirectory() as tmpdir:
        extract_tarball(tarball, Path(tmpdir))
        tree_hash = compute_tree_hash(Path(tmpdir))
    entry: Dict[str, object] = platform_fields(triple)
    entry["git-tree-sha1"] = tree_hash
    entry["download"] = [{"url": url, "sha256": sha256}]
    return entry


def write_artifacts(path: Path, artifacts: Dict[str, List[Dict[str, object]]]) -> None:
    lines: List[str] = [HEADER, ""]
    for name in sorted(artifacts):
        for entry in artifacts[name]:
            lines.append(f"[[{name}]]")
            for key in ("arch", "os", "libc", "call", "git-tree-sha1"):
                if key in entry:
                    lines.append(f"{key} = \"{entry[key]}\"")
            for download in entry.get("download", []):
                lines.append(f"[[{name}.download]]")
                lines.append(f"url = \"{download['url']}\"")
                lines.append(f"sha256 = \"{download['sha256']}\"")
            lines.append("")

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("\n".join(lines), encoding="utf-8")


def load_metadata(metadata_files: Iterable[Path], repo: str, release_tag: str) -> Dict[str, List[Dict[str, object]]]:
    artifacts: Dict[str, List[Dict[str, object]]] = {}
    for meta_path in metadata_files:
        with meta_path.open("rb") as fh:
            data = tomllib.load(fh)
        name = data.get("name", "plotpx")
        triple = data["platform"]
        archive = data["archive"]
        sha256 = data["sha256"]
        tarball_path = meta_path.parent / archive
        if not tarball_path.exists():
            raise FileNotFoundError(f"Tarball {tarball_path} referenced by {meta_path} was not found")
        url = f"https://github.com/{repo}/releases/download/{release_tag}/{archive}"
        entry = artifact_entry(tarball_path, triple, url, sha256)
        artifacts.setdefault(name, []).append(entry)
    return artifacts


def main() -> None:
    args = parse_args()
    metadata_dir = Path(args.metadata_dir)
    metadata_files = list_metadata_files(metadata_dir)
    artifacts = load_metadata(metadata_files, args.repo, args.release_tag)
    write_artifacts(Path(args.artifacts_toml), artifacts)


if __name__ == "__main__":
    main()