fix(update): preserve optional extras during fallback install
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user