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"]