GitHub Viewer
#!/usr/bin/env python3
"""Render the PyPI package compatibility matrix as a Markdown table.
Resolves every package in ``tests/packages/matrix.toml`` for each device
target with the same pip cross-resolution ``pn deps`` uses, then prints
a table suitable for ``docs/guides/pypi-packages.md``:
uv run scripts/package-matrix.py # Markdown to stdout
uv run scripts/package-matrix.py --json # machine-readable
uv run scripts/package-matrix.py --check # exit 1 if the live
# results disagree with
# the manifest
Requires network access (PyPI, BeeWare's index, Chaquopy's index). The
``packages`` CI workflow runs ``--check`` on a schedule and uploads the
Markdown as an artifact so the guide's table can be refreshed by copy
and paste rather than by hand.
"""
from __future__ import annotations
import json
import subprocess
import sys
import tomllib
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional, Sequence
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from pythonnative.project import deps # noqa: E402
from pythonnative.project.builder import CommandResult, CommandRunner # noqa: E402
from pythonnative.project.config import AppConfig # noqa: E402
MATRIX_PATH = ROOT / "tests" / "packages" / "matrix.toml"
COLUMNS = [
("iOS device", lambda cfg: deps.ios_targets(cfg, sdks=("iphoneos",))[0]),
("iOS Simulator", lambda cfg: deps.ios_targets(cfg, sdks=("iphonesimulator",), simulator_arch="arm64")[0]),
("Android arm64-v8a", lambda cfg: deps.android_targets(cfg)[0]),
("Android x86_64", lambda cfg: deps.android_targets(cfg)[1]),
]
class PipRunner(CommandRunner):
"""Run pip as a real subprocess, capturing its output."""
def run(
self,
args: Sequence[str],
*,
cwd: Optional[Path] = None,
env: Optional[dict] = None,
capture: bool = False,
) -> CommandResult:
"""Execute ``args`` and return its exit code, stdout, and stderr."""
proc = subprocess.run(list(args), cwd=cwd, env=env, capture_output=True, text=True)
return CommandResult(proc.returncode, proc.stdout, proc.stderr)
def _config(scratch: Path, python_version: str, requirement: str) -> AppConfig:
(scratch / "app").mkdir(parents=True, exist_ok=True)
(scratch / "pythonnative.toml").write_text(
f'[app]\nid = "com.pythonnative.matrix"\nname = "matrix"\npython_version = "{python_version}"\n'
f'[android]\nabi_filters = ["arm64-v8a", "x86_64"]\n[requirements]\npackages = ["{requirement}"]\n',
encoding="utf-8",
)
return AppConfig.load(scratch)
def _cell(res: deps.Resolution, name: str) -> str:
if not res.ok:
return "no wheel"
top = next((p for p in res.packages if deps._canonical(p.name) == deps._canonical(name.split("=")[0])), None)
if top is None:
return "ok"
text = top.version
if top.downgraded:
text += f" (latest {top.latest})"
return text if top.is_pure else f"{text}, binary"
def run(matrix: dict) -> List[Dict[str, object]]:
"""Resolve every manifest entry for every column target; one row per package."""
import tempfile
rows: List[Dict[str, object]] = []
with tempfile.TemporaryDirectory() as tmp:
for entry in matrix["package"]:
config = _config(Path(tmp), matrix["python_version"], entry["name"])
targets = [make(config) for _, make in COLUMNS]
resolutions = deps.resolve_all(config, targets, runner=PipRunner())
row: Dict[str, object] = {
"name": entry["name"],
"kind": entry["kind"],
"note": entry.get("note", ""),
"expected": {"ios": entry["ios"], "android": entry["android"]},
"cells": {label: _cell(res, entry["name"]) for (label, _), res in zip(COLUMNS, resolutions)},
"ok": {label: res.ok for (label, _), res in zip(COLUMNS, resolutions)},
}
rows.append(row)
return rows
def as_markdown(matrix: dict, rows: List[Dict[str, object]]) -> str:
"""Render ``rows`` as the Markdown table embedded in the PyPI packages guide."""
head = ["Package", "Kind", *[label for label, _ in COLUMNS], "Notes"]
out = [
f"",
"| " + " | ".join(head) + " |",
"| " + " | ".join("---" for _ in head) + " |",
]
for row in rows:
cells = row["cells"]
assert isinstance(cells, dict)
out.append(
"| `"
+ str(row["name"])
+ "` | "
+ str(row["kind"])
+ " | "
+ " | ".join(str(cells[label]) for label, _ in COLUMNS)
+ " | "
+ str(row["note"])
+ " |"
)
return "\n".join(out)
def mismatches(rows: List[Dict[str, object]]) -> List[str]:
"""Describe every cell whose live outcome disagrees with the manifest."""
problems: List[str] = []
for row in rows:
expected = row["expected"]
ok = row["ok"]
assert isinstance(expected, dict) and isinstance(ok, dict)
for label, _ in COLUMNS:
platform = "ios" if label.startswith("iOS") else "android"
if ok[label] != expected[platform]:
problems.append(f"{row['name']} on {label}: expected {'ok' if expected[platform] else 'no wheel'}")
return problems
def main(argv: List[str]) -> int:
"""CLI entry point; see the module docstring for flags."""
with open(MATRIX_PATH, "rb") as handle:
matrix = tomllib.load(handle)
rows = run(matrix)
if "--json" in argv:
print(json.dumps({"python_version": matrix["python_version"], "packages": rows}, indent=2))
else:
print(as_markdown(matrix, rows))
problems = mismatches(rows)
if problems:
print("\nLive results disagree with tests/packages/matrix.toml:", file=sys.stderr)
for problem in problems:
print(f" - {problem}", file=sys.stderr)
return 1 if "--check" in argv else 0
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))