diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9dbf455a24c..5cab5cd588f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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") diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 1de8f26a386..f059e54ac05 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -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"] diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index bdc72681bb5..645b3b24ea4 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -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."""