Files
hermes-agent/scripts/termux/probe_python_linkage.py
ethernet 7f6d63bcbb fix(termux): classify bionic from its interpreter and run the Linux tests
The POSIX runner had a blank line after exec env, so it printed the environment and never ran pytest. Keep the command attached and prove failure propagation. The actual payload records ANDROID_API_LEVEL in sysconfig; use that instead of looking for text in a guessed libc file. Rebuild unproven runtime-library extracts from verified archives.
2026-09-06 14:12:12 -04:00

134 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Diagnose the actual anydoc wheel against the pinned bionic interpreter.
This standalone CI diagnostic builds the locked sdist, inspects the ELF
linkage, and tests the same extension with an explicit libpython dependency.
It does not change any package source or publish release artifacts.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import sysconfig
import tempfile
import tomllib
import zipfile
def run(argv: list[str], *, env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
print("+", " ".join(argv), flush=True)
return subprocess.run(argv, check=True, env=env)
def import_anydoc(python: Path, site: Path, env: dict[str, str]) -> subprocess.CompletedProcess:
return subprocess.run(
[str(python), "-c", "import anydoc; print('ANYDOC_IMPORT_OK', anydoc.__file__)"],
env={**env, "PYTHONPATH": str(site)},
capture_output=True,
text=True,
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--repo", type=Path, required=True)
ap.add_argument("--payload", type=Path, required=True)
ap.add_argument("--output", type=Path, required=True)
ap.add_argument("--built-wheel", type=Path)
args = ap.parse_args()
sys.path.insert(0, str(args.repo))
from pm.store import current_target
target = current_target()
print("PAYLOAD_TARGET", target, "ANDROID_API_LEVEL", sysconfig.get_config_var("ANDROID_API_LEVEL"), flush=True)
if target != "linux-arm64-bionic":
raise RuntimeError(f"payload interpreter misclassified: {target}")
prefix = Path("/data/data/com.termux/files/usr")
python = args.payload / "python" / prefix.relative_to("/") / "bin/python3.11"
uv = args.payload / "uv" / prefix.relative_to("/") / "bin/uv"
pylib = python.parent.parent / "lib"
runtime_libs = args.payload / "runtime-libs/lib"
env = {**os.environ, "LD_LIBRARY_PATH": f"{pylib}:{runtime_libs}:{prefix}/lib"}
args.output.mkdir(parents=True, exist_ok=True)
lock = tomllib.loads((args.repo / "uv.lock").read_text(encoding="utf-8"))
package = next(p for p in lock["package"] if p["name"] == "firecrawl-anydoc")
requirement = f"firecrawl-anydoc=={package['version']}"
with tempfile.TemporaryDirectory(prefix="hermes-linkage-", dir=prefix / "tmp") as tmp:
venv = Path(tmp) / "venv"
run([str(uv), "venv", "--python", str(python), str(venv)], env=env)
vp = venv / "bin/python"
run([str(uv), "pip", "install", "--python", str(vp), "pip", "packaging"], env=env)
build_env = {
**env,
"CARGO_BUILD_JOBS": "1",
"MAKEFLAGS": "-j1",
"ANDROID_API_LEVEL": "24",
"RUSTFLAGS": f"-L{pylib}",
"LDFLAGS": f"-L{pylib}",
}
if args.built_wheel:
shutil.copy2(args.built_wheel, args.output / args.built_wheel.name)
else:
run([
str(vp), "-m", "pip", "wheel", "--no-deps", "--no-cache-dir",
"--no-binary", ":all:", "-w", str(args.output), requirement,
], env=build_env)
wheels = sorted(args.output.glob("firecrawl_anydoc-*.whl"))
if len(wheels) != 1:
raise RuntimeError(f"expected one anydoc wheel, found {wheels}")
site = Path(tmp) / "site"
with zipfile.ZipFile(wheels[0]) as wheel:
wheel.extractall(site)
extension = next(site.glob("anydoc/_anydoc*.so"))
library = next(pylib.glob("libpython3.11.so.*"))
readelf = shutil.which("llvm-readelf") or shutil.which("readelf")
if not readelf:
raise RuntimeError("builder has no ELF inspection tool")
run([readelf, "-d", str(extension)])
symbols = subprocess.run([readelf, "--dyn-syms", "--wide", str(library)], check=True, capture_output=True, text=True)
print("libpython export:", *[line for line in symbols.stdout.splitlines() if "_Py_NoneStruct" in line], sep="\n")
baseline = import_anydoc(vp, site, env)
print("BASELINE_EXIT", baseline.returncode, flush=True)
print(baseline.stdout, baseline.stderr, flush=True)
soname = subprocess.run(["patchelf", "--print-soname", str(library)], check=True, capture_output=True, text=True).stdout.strip()
needed = subprocess.run(["patchelf", "--print-needed", str(extension)], check=True, capture_output=True, text=True).stdout.splitlines()
from python_linkage import repair_wheel
repaired = repair_wheel(wheels[0], library)
if repaired == 0:
raise RuntimeError("production wheel repair did not change the failing extension")
shutil.rmtree(site)
with zipfile.ZipFile(wheels[0]) as wheel:
wheel.extractall(site)
explicit = import_anydoc(vp, site, env)
print("EXPLICIT_LINK_EXIT", explicit.returncode, flush=True)
print(explicit.stdout, explicit.stderr, flush=True)
evidence = {
"requirement": requirement,
"wheel": wheels[0].name,
"libpython_soname": soname,
"original_needed": needed,
"baseline_exit": baseline.returncode,
"baseline_error": baseline.stderr,
"explicit_link_exit": explicit.returncode,
"explicit_link_error": explicit.stderr,
}
(args.output / "linkage-evidence.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8")
if baseline.returncode == 0:
raise RuntimeError("baseline did not reproduce the native import failure")
if "_Py_NoneStruct" not in baseline.stderr:
raise RuntimeError("baseline failed for a different reason")
if explicit.returncode:
raise RuntimeError("explicit libpython dependency did not fix the import")
print("LINKAGE_CAUSE_PROVEN", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())