diff --git a/hermes_cli/psutil_android.py b/hermes_cli/psutil_android.py index c029324542cdb..5b752326f4d2c 100644 --- a/hermes_cli/psutil_android.py +++ b/hermes_cli/psutil_android.py @@ -2,6 +2,9 @@ from __future__ import annotations +import os +import platform +import re import shutil import tarfile from pathlib import Path, PurePosixPath @@ -17,11 +20,71 @@ MARKER = 'LINUX = sys.platform.startswith("linux")' REPLACEMENT = 'LINUX = sys.platform.startswith(("linux", "android"))' +_DEFAULT_ANDROID_API_LEVEL = 24 +_ANDROID_ABI_BY_MACHINE = { + "aarch64": "arm64_v8a", + "arm64": "arm64_v8a", + "armv7l": "armeabi_v7a", + "armv8l": "armeabi_v7a", + "arm": "armeabi_v7a", + "x86_64": "x86_64", + "amd64": "x86_64", + "i386": "x86", + "i486": "x86", + "i586": "x86", + "i686": "x86", + "x86": "x86", +} +_BDIST_WHEEL_SECTION_RE = re.compile( + r"(?ms)^\[bdist_wheel\]\s*\n(?P.*?)(?=^\[|\Z)" +) +_PLAT_NAME_RE = re.compile(r"(?m)^plat_name\s*=.*$") + class PsutilAndroidInstallError(RuntimeError): """Raised when the pinned psutil sdist is missing or unsafe.""" +def android_wheel_platform_tag( + *, + api_level: int | str | None = None, + machine: str | None = None, +) -> str: + """Return the PEP 738 wheel platform tag for the current Termux target. + + Termux packages target Android API 24 even when the phone itself runs a + newer Android release. Using the runtime API (for example 36) makes uv + reject a locally built wheel because its interpreter target is API 24. + ``HERMES_ANDROID_API_LEVEL`` is an explicit build-target override; the + generic ``ANDROID_API_LEVEL`` variable is intentionally ignored because + installers commonly populate it from ``getprop ro.build.version.sdk``. + """ + raw_api_level = ( + api_level + if api_level is not None + else os.environ.get("HERMES_ANDROID_API_LEVEL", _DEFAULT_ANDROID_API_LEVEL) + ) + try: + normalized_api_level = int(raw_api_level) + except (TypeError, ValueError) as exc: + raise PsutilAndroidInstallError( + f"Invalid Android API level for wheel tag: {raw_api_level!r}" + ) from exc + if normalized_api_level < 21: + raise PsutilAndroidInstallError( + f"Android API level must be 21 or newer, got {normalized_api_level}" + ) + + normalized_machine = (machine or platform.machine()).strip().lower() + abi = _ANDROID_ABI_BY_MACHINE.get(normalized_machine) + if abi is None: + raise PsutilAndroidInstallError( + "Unsupported Android architecture for wheel tag: " + f"{normalized_machine or ''}" + ) + return f"android_{normalized_api_level}_{abi}" + + def _normalize_member_parts(member_name: str) -> tuple[str, ...]: path = PurePosixPath(member_name) parts = tuple(part for part in path.parts if part not in ("", ".")) @@ -64,13 +127,60 @@ def _safe_extract_tar_gz(archive: Path, destination: Path) -> None: pass -def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> Path: - """Safely extract the pinned psutil sdist and patch it for Android.""" +def _configure_android_wheel_tag(src_root: Path, platform_tag: str) -> None: + """Make setuptools emit an Android wheel without leaking env into uv. + + Exporting ``_PYTHON_HOST_PLATFORM`` around ``uv pip`` changes the platform + uv sees while inspecting the interpreter, which fails with ``Unknown + operating system: android_...``. A package-local ``bdist_wheel`` setting + affects only the wheel build and leaves uv's interpreter probe untouched. + """ + setup_cfg = src_root / "setup.cfg" + try: + content = setup_cfg.read_text(encoding="utf-8") if setup_cfg.exists() else "" + except OSError as exc: + raise PsutilAndroidInstallError("Failed to read psutil setup.cfg") from exc + + section_match = _BDIST_WHEEL_SECTION_RE.search(content) + if section_match is None: + separator = "" if not content else "\n" if content.endswith("\n") else "\n\n" + updated = ( + f"{content}{separator}[bdist_wheel]\n" + f"plat_name = {platform_tag}\n" + ) + else: + body = section_match.group("body") + if _PLAT_NAME_RE.search(body): + updated_body = _PLAT_NAME_RE.sub( + f"plat_name = {platform_tag}", body, count=1 + ) + else: + updated_body = f"plat_name = {platform_tag}\n{body}" + updated = ( + content[: section_match.start("body")] + + updated_body + + content[section_match.end("body") :] + ) + + try: + setup_cfg.write_text(updated, encoding="utf-8") + except OSError as exc: + raise PsutilAndroidInstallError("Failed to write psutil setup.cfg") from exc + + +def prepare_patched_psutil_sdist( + archive: Path, + destination: Path, + *, + platform_tag: str | None = None, +) -> Path: + """Safely extract psutil and patch Android detection and wheel metadata.""" _safe_extract_tar_gz(archive, destination) src_roots = sorted( ( - path for path in destination.iterdir() + path + for path in destination.iterdir() if path.is_dir() and path.name.startswith("psutil-") ), key=lambda path: path.name, @@ -105,4 +215,9 @@ def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> Path: raise PsutilAndroidInstallError( f"Failed to write {common_py.relative_to(src_root)!s}" ) from exc + + _configure_android_wheel_tag( + src_root, + platform_tag or android_wheel_platform_tag(), + ) return src_root diff --git a/tests/hermes_cli/test_psutil_android_wheel_tag.py b/tests/hermes_cli/test_psutil_android_wheel_tag.py new file mode 100644 index 0000000000000..cbe18c18dc86d --- /dev/null +++ b/tests/hermes_cli/test_psutil_android_wheel_tag.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import io +import subprocess +import sys +import tarfile +from pathlib import Path + +import pytest + +from hermes_cli.psutil_android import ( + MARKER, + PsutilAndroidInstallError, + android_wheel_platform_tag, + prepare_patched_psutil_sdist, +) + + +def _add_dir(tf: tarfile.TarFile, name: str) -> None: + info = tarfile.TarInfo(name) + info.type = tarfile.DIRTYPE + info.mode = 0o755 + tf.addfile(info) + + +def _add_file(tf: tarfile.TarFile, name: str, content: str) -> None: + payload = content.encode() + info = tarfile.TarInfo(name) + info.size = len(payload) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(payload)) + + +def _archive( + path: Path, + *, + setup_cfg: str | None = None, + setup_py: str | None = None, +) -> None: + with tarfile.open(path, "w:gz") as tf: + _add_dir(tf, "psutil-7.2.2") + _add_dir(tf, "psutil-7.2.2/psutil") + _add_file(tf, "psutil-7.2.2/psutil/_common.py", f"{MARKER}\n") + if setup_cfg is not None: + _add_file(tf, "psutil-7.2.2/setup.cfg", setup_cfg) + if setup_py is not None: + _add_file(tf, "psutil-7.2.2/setup.py", setup_py) + + +@pytest.mark.parametrize( + ("machine", "expected_abi"), + [ + ("aarch64", "arm64_v8a"), + ("arm64", "arm64_v8a"), + ("armv8l", "armeabi_v7a"), + ("x86_64", "x86_64"), + ("i686", "x86"), + ], +) +def test_android_wheel_platform_tag_maps_termux_architectures(machine, expected_abi): + assert android_wheel_platform_tag(machine=machine) == f"android_24_{expected_abi}" + + +def test_android_wheel_platform_ignores_runtime_android_api(monkeypatch): + monkeypatch.setenv("ANDROID_API_LEVEL", "36") + monkeypatch.delenv("HERMES_ANDROID_API_LEVEL", raising=False) + + assert android_wheel_platform_tag(machine="aarch64") == "android_24_arm64_v8a" + + +def test_android_wheel_platform_supports_explicit_build_target(monkeypatch): + monkeypatch.setenv("HERMES_ANDROID_API_LEVEL", "28") + + assert android_wheel_platform_tag(machine="aarch64") == "android_28_arm64_v8a" + + +def test_android_wheel_platform_rejects_unknown_architecture(): + with pytest.raises(PsutilAndroidInstallError, match="Unsupported Android architecture"): + android_wheel_platform_tag(machine="mips64") + + +def test_prepare_patched_sdist_adds_android_bdist_wheel_config(tmp_path): + archive = tmp_path / "psutil.tar.gz" + _archive(archive) + + src = prepare_patched_psutil_sdist( + archive, + tmp_path / "extract", + platform_tag="android_24_arm64_v8a", + ) + + assert (src / "setup.cfg").read_text() == ( + "[bdist_wheel]\nplat_name = android_24_arm64_v8a\n" + ) + + +def test_prepare_patched_sdist_preserves_existing_setup_config(tmp_path): + archive = tmp_path / "psutil.tar.gz" + _archive( + archive, + setup_cfg="[metadata]\nlicense_files = LICENSE\n\n[bdist_wheel]\nuniversal = 0\n", + ) + + src = prepare_patched_psutil_sdist( + archive, + tmp_path / "extract", + platform_tag="android_24_arm64_v8a", + ) + config = (src / "setup.cfg").read_text() + + assert "[metadata]\nlicense_files = LICENSE" in config + assert "[bdist_wheel]\nplat_name = android_24_arm64_v8a\nuniversal = 0" in config + + +def test_patched_sdist_builds_an_android_tagged_wheel(tmp_path): + archive = tmp_path / "psutil.tar.gz" + _archive( + archive, + setup_py=( + "from setuptools import setup\n" + "setup(name='psutil', version='7.2.2', packages=['psutil'])\n" + ), + ) + src = prepare_patched_psutil_sdist( + archive, + tmp_path / "extract", + platform_tag="android_24_arm64_v8a", + ) + + subprocess.run( + [sys.executable, "setup.py", "bdist_wheel"], + cwd=src, + check=True, + capture_output=True, + text=True, + ) + + wheel_names = [wheel.name for wheel in (src / "dist").glob("*.whl")] + assert wheel_names == ["psutil-7.2.2-py3-none-android_24_arm64_v8a.whl"]