fix(update): preserve optional extras during fallback install

This commit is contained in:
kshitijk4poor
2026-04-02 11:01:10 +05:30
committed by Teknium
parent 5101f853ba
commit c91f4ef4ed
2 changed files with 91 additions and 44 deletions

View File

@@ -47,6 +47,7 @@ import argparse
import os
import subprocess
import sys
import tomllib
from pathlib import Path
from typing import Optional
@@ -2686,24 +2687,15 @@ def _update_via_zip(args):
if removed:
print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}")
# Reinstall Python dependencies (try .[all] first for optional extras,
# fall back to . if extras fail — mirrors the install script behavior)
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
try:
subprocess.run(
[uv_bin, "pip", "install", "-e", ".[all]", "--quiet"],
cwd=PROJECT_ROOT, check=True, env=uv_env,
)
except subprocess.CalledProcessError:
print(" ⚠ Optional extras failed, installing base dependencies...")
subprocess.run(
[uv_bin, "pip", "install", "-e", ".", "--quiet"],
cwd=PROJECT_ROOT, check=True, env=uv_env,
)
_install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
else:
# Use sys.executable to explicitly call the venv's pip module,
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
@@ -2718,11 +2710,7 @@ def _update_via_zip(args):
cwd=PROJECT_ROOT,
check=True,
)
try:
subprocess.run(pip_cmd + ["install", "-e", ".[all]", "--quiet"], cwd=PROJECT_ROOT, check=True)
except subprocess.CalledProcessError:
print(" ⚠ Optional extras failed, installing base dependencies...")
subprocess.run(pip_cmd + ["install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
_install_python_dependencies_with_optional_fallback(pip_cmd)
# Sync skills
try:
@@ -2922,6 +2910,67 @@ def _invalidate_update_cache():
except Exception:
pass
def _load_installable_optional_extras() -> list[str]:
"""Return optional dependency groups except the aggregate ``all`` extra."""
try:
with (PROJECT_ROOT / "pyproject.toml").open("rb") as handle:
project = tomllib.load(handle).get("project", {})
except Exception:
return []
optional_deps = project.get("optional-dependencies", {})
if not isinstance(optional_deps, dict):
return []
return [name for name in optional_deps if name != "all"]
def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
"""Install base deps plus as many optional extras as the environment supports."""
try:
subprocess.run(
install_cmd_prefix + ["install", "-e", ".[all]", "--quiet"],
cwd=PROJECT_ROOT,
check=True,
env=env,
)
return
except subprocess.CalledProcessError:
print(" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually...")
subprocess.run(
install_cmd_prefix + ["install", "-e", ".", "--quiet"],
cwd=PROJECT_ROOT,
check=True,
env=env,
)
failed_extras: list[str] = []
installed_extras: list[str] = []
for extra in _load_installable_optional_extras():
try:
subprocess.run(
install_cmd_prefix + ["install", "-e", f".[{extra}]", "--quiet"],
cwd=PROJECT_ROOT,
check=True,
env=env,
)
installed_extras.append(extra)
except subprocess.CalledProcessError:
failed_extras.append(extra)
if installed_extras:
print(f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}")
if failed_extras:
print(f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}")
def cmd_update(args):
"""Update Hermes Agent to the latest version."""
import shutil
@@ -3096,23 +3145,14 @@ def cmd_update(args):
if removed:
print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}")
# Reinstall Python dependencies (try .[all] first for optional extras,
# fall back to . if extras fail — mirrors the install script behavior)
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
uv_bin = shutil.which("uv")
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
try:
subprocess.run(
[uv_bin, "pip", "install", "-e", ".[all]", "--quiet"],
cwd=PROJECT_ROOT, check=True, env=uv_env,
)
except subprocess.CalledProcessError:
print(" ⚠ Optional extras failed, installing base dependencies...")
subprocess.run(
[uv_bin, "pip", "install", "-e", ".", "--quiet"],
cwd=PROJECT_ROOT, check=True, env=uv_env,
)
_install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
else:
# Use sys.executable to explicitly call the venv's pip module,
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
@@ -3127,11 +3167,7 @@ def cmd_update(args):
cwd=PROJECT_ROOT,
check=True,
)
try:
subprocess.run(pip_cmd + ["install", "-e", ".[all]", "--quiet"], cwd=PROJECT_ROOT, check=True)
except subprocess.CalledProcessError:
print(" ⚠ Optional extras failed, installing base dependencies...")
subprocess.run(pip_cmd + ["install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
_install_python_dependencies_with_optional_fallback(pip_cmd)
# Check for Node.js deps
if (PROJECT_ROOT / "package.json").exists():

View File

@@ -324,10 +324,11 @@ def _setup_update_mocks(monkeypatch, tmp_path):
monkeypatch.setattr(hermes_config, "migrate_config", lambda **kw: {"env_added": [], "config_added": []})
def test_cmd_update_tries_extras_first_then_falls_back(monkeypatch, tmp_path):
"""When .[all] fails, update should fall back to . instead of aborting."""
def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypatch, tmp_path, capsys):
"""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"])
recorded = []
@@ -341,12 +342,14 @@ def test_cmd_update_tries_extras_first_then_falls_back(monkeypatch, tmp_path):
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
if cmd == ["git", "pull", "origin", "main"]:
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
# .[all] fails
if ".[all]" in cmd:
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[all]", "--quiet"]:
raise CalledProcessError(returncode=1, cmd=cmd)
# bare . succeeds
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".", "--quiet"]:
return SimpleNamespace(returncode=0)
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[matrix]", "--quiet"]:
raise CalledProcessError(returncode=1, cmd=cmd)
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[mcp]", "--quiet"]:
return SimpleNamespace(returncode=0)
return SimpleNamespace(returncode=0)
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
@@ -354,9 +357,17 @@ def test_cmd_update_tries_extras_first_then_falls_back(monkeypatch, tmp_path):
hermes_main.cmd_update(SimpleNamespace())
install_cmds = [c for c in recorded if "pip" in c and "install" in c]
assert len(install_cmds) == 2
assert ".[all]" in install_cmds[0]
assert "." in install_cmds[1] and ".[all]" not in install_cmds[1]
assert install_cmds == [
["/usr/bin/uv", "pip", "install", "-e", ".[all]", "--quiet"],
["/usr/bin/uv", "pip", "install", "-e", ".", "--quiet"],
["/usr/bin/uv", "pip", "install", "-e", ".[matrix]", "--quiet"],
["/usr/bin/uv", "pip", "install", "-e", ".[mcp]", "--quiet"],
]
out = capsys.readouterr().out
assert "retrying extras individually" in out
assert "Reinstalled optional extras individually: mcp" in out
assert "Skipped optional extras that still failed: matrix" in out
def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):