fix(update): use termux-all uv fallback path on Termux

This commit is contained in:
adybag14-cyber 2026-05-09 21:54:51 +01:00 committed by Teknium
parent 3863d6d344
commit 6d5d467d39
3 changed files with 86 additions and 32 deletions

View file

@ -6445,13 +6445,11 @@ def _invalidate_update_cache():
pass
def _load_installable_optional_extras() -> list[str]:
"""Return the optional extras referenced by the ``all`` group.
def _load_installable_optional_extras(group: str = "all") -> list[str]:
"""Return optional extras referenced by a dependency group.
Only extras that ``[all]`` actually pulls in are retried individually.
Extras outside ``[all]`` (e.g. ``rl``, ``yc-bench``) are intentionally
excluded they have heavy or platform-specific deps that most users
never installed.
``group`` is usually ``all`` (desktop/server broad install) or
``termux-all`` (Termux-compatible broad install).
"""
try:
import tomllib
@ -6465,11 +6463,9 @@ def _load_installable_optional_extras() -> list[str]:
if not isinstance(optional_deps, dict):
return []
# Parse the [all] group to find which extras it references.
# Entries look like "hermes-agent[matrix]" or "package-name[extra]".
all_refs = optional_deps.get("all", [])
refs = optional_deps.get(group, [])
referenced: list[str] = []
for ref in all_refs:
for ref in refs:
if "[" in ref and "]" in ref:
name = ref.split("[", 1)[1].split("]", 1)[0]
if name in optional_deps:
@ -6521,25 +6517,16 @@ def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
group: str = "all",
) -> None:
"""Install base deps plus as many optional extras as the environment supports.
We intentionally do NOT pass ``--quiet`` to pip. On platforms without
prebuilt wheels for some extras (Termux/Android aarch64, older musl
distros, fresh Raspberry Pi) pip has to compile C/Rust extensions from
source, which can take several minutes with zero network activity.
Without progress output the call looks like a hang and users Ctrl+C it.
Pip's default output is proportional to actual work (one line per
Collecting/Building/Installing step), so keeping it visible costs
nothing on fast hardware and prevents the "hermes update hangs" reports
on slow hardware.
We also add periodic heartbeat lines in case the resolver/build backend is
itself silent for long stretches.
By default this targets ``.[all]``; Termux callers can pass
``group='termux-all'`` to use the curated Android-compatible profile.
"""
try:
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "-e", ".[all]"],
install_cmd_prefix + ["install", "-e", f".[{group}]"],
env=env,
)
return
@ -6555,7 +6542,7 @@ def _install_python_dependencies_with_optional_fallback(
failed_extras: list[str] = []
installed_extras: list[str] = []
for extra in _load_installable_optional_extras():
for extra in _load_installable_optional_extras(group=group):
try:
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "-e", f".[{extra}]"],
@ -7390,16 +7377,20 @@ def _cmd_update_impl(args, gateway_mode: bool):
print("→ Updating Python dependencies...")
pip_cmd = [sys.executable, "-m", "pip"]
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
install_group = "all"
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
install_group = "termux-all"
print(" → Termux detected: using uv + curated termux-all optional profile...")
if _is_termux_env(uv_env) and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat([uv_bin, "pip"], env=uv_env)
_install_python_dependencies_with_optional_fallback(
[uv_bin, "pip"], env=uv_env
[uv_bin, "pip"], env=uv_env, group=install_group
)
else:
# Use sys.executable to explicitly call the venv's pip module,
@ -7420,10 +7411,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
cwd=PROJECT_ROOT,
check=True,
)
if _is_termux_env():
install_group = "termux-all"
print(" → Termux detected: using curated termux-all optional profile...")
if _is_termux_env() and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")

View file

@ -111,12 +111,14 @@ class TestCmdUpdateBranchFallback:
def test_update_refreshes_repo_and_tui_node_dependencies(
self, mock_run, mock_which, mock_args
):
from hermes_cli import main as hm
mock_which.side_effect = {"uv": "/usr/bin/uv", "npm": "/usr/bin/npm"}.get
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
with patch.object(hm, "_is_termux_env", return_value=False):
cmd_update(mock_args)
npm_calls = [
(call.args[0], call.kwargs.get("cwd"))
@ -136,12 +138,15 @@ class TestCmdUpdateBranchFallback:
"--no-audit",
"--progress=false",
]
assert npm_calls == [
assert npm_calls[:2] == [
(full_flags, PROJECT_ROOT),
(full_flags, PROJECT_ROOT / "ui-tui"),
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"),
(["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "web"),
]
if len(npm_calls) > 2:
assert npm_calls[2:] == [
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"),
(["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "web"),
]
def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys):
"""Dashboard/web updates apply non-interactive migrations before restart."""
@ -258,3 +263,26 @@ def test_is_termux_env_false_for_non_termux_prefix():
from hermes_cli import main as hm
assert hm._is_termux_env({"PREFIX": "/usr/local"}) is False
def test_load_installable_optional_extras_supports_termux_group(tmp_path, monkeypatch):
from hermes_cli import main as hm
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"""
[project]
name = "x"
version = "0.0.0"
[project.optional-dependencies]
all = ["x[mcp]"]
termux-all = ["x[termux]", "x[mcp]"]
mcp = ["mcp>=1"]
termux = ["rich>=14"]
""".strip()
)
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
assert hm._load_installable_optional_extras(group="all") == ["mcp"]
assert hm._load_installable_optional_extras(group="termux-all") == ["termux", "mcp"]

View file

@ -311,7 +311,8 @@ def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypa
"""When .[all] fails, update should keep base deps and retry extras individually."""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_load_installable_optional_extras", lambda: ["matrix", "mcp"])
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
monkeypatch.setattr(hermes_main, "_load_installable_optional_extras", lambda group="all": ["matrix", "mcp"])
recorded = []
@ -360,6 +361,7 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):
"""When .[all] succeeds, no fallback should be attempted."""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
recorded = []
@ -384,6 +386,36 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):
assert ".[all]" in install_cmds[0]
def test_install_with_optional_fallback_honors_custom_group(monkeypatch):
"""Termux update path should target .[termux-all] when requested."""
calls = []
monkeypatch.setattr(
hermes_main,
"_load_installable_optional_extras",
lambda group="all": ["termux", "mcp"] if group == "termux-all" else [],
)
def fake_run_with_heartbeat(cmd, **kwargs):
calls.append(cmd)
if cmd[-1] == ".[termux-all]":
raise CalledProcessError(returncode=1, cmd=cmd)
return None
monkeypatch.setattr(hermes_main, "_run_install_with_heartbeat", fake_run_with_heartbeat)
hermes_main._install_python_dependencies_with_optional_fallback(
["/usr/bin/uv", "pip"],
group="termux-all",
)
assert calls == [
["/usr/bin/uv", "pip", "install", "-e", ".[termux-all]"],
["/usr/bin/uv", "pip", "install", "-e", "."],
["/usr/bin/uv", "pip", "install", "-e", ".[termux]"],
["/usr/bin/uv", "pip", "install", "-e", ".[mcp]"],
]
def test_install_heartbeat_prints_when_dependency_install_is_silent(monkeypatch, capsys):
"""Long quiet installs should emit periodic heartbeat lines."""