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 os
import subprocess import subprocess
import sys import sys
import tomllib
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -2686,24 +2687,15 @@ def _update_via_zip(args):
if removed: if removed:
print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}") print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}")
# Reinstall Python dependencies (try .[all] first for optional extras, # Reinstall Python dependencies. Prefer .[all], but if one optional extra
# fall back to . if extras fail — mirrors the install script behavior) # 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...") print("→ Updating Python dependencies...")
import subprocess import subprocess
uv_bin = shutil.which("uv") uv_bin = shutil.which("uv")
if uv_bin: if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
try: _install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
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,
)
else: else:
# Use sys.executable to explicitly call the venv's pip module, # Use sys.executable to explicitly call the venv's pip module,
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu. # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
@@ -2718,11 +2710,7 @@ def _update_via_zip(args):
cwd=PROJECT_ROOT, cwd=PROJECT_ROOT,
check=True, check=True,
) )
try: _install_python_dependencies_with_optional_fallback(pip_cmd)
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)
# Sync skills # Sync skills
try: try:
@@ -2922,6 +2910,67 @@ def _invalidate_update_cache():
except Exception: except Exception:
pass 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): def cmd_update(args):
"""Update Hermes Agent to the latest version.""" """Update Hermes Agent to the latest version."""
import shutil import shutil
@@ -3096,23 +3145,14 @@ def cmd_update(args):
if removed: if removed:
print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}") print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}")
# Reinstall Python dependencies (try .[all] first for optional extras, # Reinstall Python dependencies. Prefer .[all], but if one optional extra
# fall back to . if extras fail — mirrors the install script behavior) # 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...") print("→ Updating Python dependencies...")
uv_bin = shutil.which("uv") uv_bin = shutil.which("uv")
if uv_bin: if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
try: _install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
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,
)
else: else:
# Use sys.executable to explicitly call the venv's pip module, # Use sys.executable to explicitly call the venv's pip module,
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu. # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
@@ -3127,11 +3167,7 @@ def cmd_update(args):
cwd=PROJECT_ROOT, cwd=PROJECT_ROOT,
check=True, check=True,
) )
try: _install_python_dependencies_with_optional_fallback(pip_cmd)
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)
# Check for Node.js deps # Check for Node.js deps
if (PROJECT_ROOT / "package.json").exists(): 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": []}) 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): def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypatch, tmp_path, capsys):
"""When .[all] fails, update should fall back to . instead of aborting.""" """When .[all] fails, update should keep base deps and retry extras individually."""
_setup_update_mocks(monkeypatch, tmp_path) _setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) 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 = [] 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) return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
if cmd == ["git", "pull", "origin", "main"]: if cmd == ["git", "pull", "origin", "main"]:
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
# .[all] fails if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[all]", "--quiet"]:
if ".[all]" in cmd:
raise CalledProcessError(returncode=1, cmd=cmd) raise CalledProcessError(returncode=1, cmd=cmd)
# bare . succeeds
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".", "--quiet"]: if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".", "--quiet"]:
return SimpleNamespace(returncode=0) 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) return SimpleNamespace(returncode=0)
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) 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()) hermes_main.cmd_update(SimpleNamespace())
install_cmds = [c for c in recorded if "pip" in c and "install" in c] install_cmds = [c for c in recorded if "pip" in c and "install" in c]
assert len(install_cmds) == 2 assert install_cmds == [
assert ".[all]" in install_cmds[0] ["/usr/bin/uv", "pip", "install", "-e", ".[all]", "--quiet"],
assert "." in install_cmds[1] and ".[all]" not in install_cmds[1] ["/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): def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):