Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 118 additions & 3 deletions hermes_cli/psutil_android.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from __future__ import annotations

import os
import platform
import re
import shutil
import tarfile
from pathlib import Path, PurePosixPath
Expand All @@ -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<body>.*?)(?=^\[|\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 '<empty>'}"
)
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 ("", "."))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
139 changes: 139 additions & 0 deletions tests/hermes_cli/test_psutil_android_wheel_tag.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading