mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-23 05:31:23 +00:00
fix(update): refresh lazy-installed backends on hermes update (#25766)
Pyproject's [all] extra was slimmed down in May 2026 — ~20 optional
backends moved to tools/lazy_deps.py and only install on first use.
hermes update runs uv pip install -e .[all] which doesn't touch any of
them, so pin bumps in LAZY_DEPS (CVE response, transitive fixes) were
silently ignored on already-activated backends.
Two changes:
1. _is_satisfied() now parses the spec and checks the installed version
against the constraint via packaging.specifiers. Previously it
returned True the moment the package name was importable, which made
ensure() a name-presence gate rather than a version-pin gate.
2. New active_features() / refresh_active_features() pair: lists every
feature with at least one of its packages currently installed, then
re-runs ensure() on each. Refresh is invoked at the end of
_cmd_update_impl, right after the [all] install completes. Cold
backends (never activated) stay quiet — no churn for them.
Output during update is one summary block:
→ Refreshing 4 active lazy backend(s)...
↑ 1 refreshed: provider.anthropic
✓ 3 already current
or
⚠ memory.honcho failed to refresh: <pip stderr>
Failures never raise out of update — backends keep their previously-
installed version and we tell the user to rerun once upstream is fixed.
security.allow_lazy_installs=false is honored: features get marked
"skipped" with the reason shown.
Tests: 18 new unit tests covering version-aware satisfaction (exact pin,
range, extras blocks, missing package, malformed spec), active feature
discovery, and refresh status reporting. All 61 lazy_deps tests pass.
This commit is contained in:
parent
436a0a271e
commit
72b5dd8658
3 changed files with 362 additions and 5 deletions
|
|
@ -6827,6 +6827,74 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_active_lazy_features() -> None:
|
||||||
|
"""Refresh lazy-installed backends after a code update.
|
||||||
|
|
||||||
|
When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most
|
||||||
|
optional backends moved to ``tools/lazy_deps.py`` and only install on
|
||||||
|
first use. ``hermes update`` runs ``uv pip install -e .[all]`` which
|
||||||
|
leaves those packages untouched — so if we bump a pin in
|
||||||
|
:data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already
|
||||||
|
activated the backend keep the stale version forever.
|
||||||
|
|
||||||
|
This function asks lazy_deps which features the user has previously
|
||||||
|
activated and reinstalls them under the current pins. Features the
|
||||||
|
user never enabled stay quiet — no churn for cold backends.
|
||||||
|
|
||||||
|
Never raises. A failure here must not block the rest of the update.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from tools import lazy_deps
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Lazy refresh skipped (import failed): %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
active = lazy_deps.active_features()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
return
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = lazy_deps.refresh_active_features(prompt=False)
|
||||||
|
except Exception as exc:
|
||||||
|
# refresh_active_features is documented as never-raise, but defend
|
||||||
|
# the update flow against future regressions.
|
||||||
|
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
refreshed = [f for f, s in results.items() if s == "refreshed"]
|
||||||
|
current = [f for f, s in results.items() if s == "current"]
|
||||||
|
failed = [(f, s) for f, s in results.items() if s.startswith("failed:")]
|
||||||
|
skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")]
|
||||||
|
|
||||||
|
if refreshed:
|
||||||
|
print(f" ↑ {len(refreshed)} refreshed: {', '.join(refreshed)}")
|
||||||
|
if current:
|
||||||
|
print(f" ✓ {len(current)} already current")
|
||||||
|
if skipped:
|
||||||
|
# Most common reason: security.allow_lazy_installs=false. Show one
|
||||||
|
# line so the user knows why; not an error.
|
||||||
|
names = ", ".join(f for f, _ in skipped)
|
||||||
|
reason = skipped[0][1].split(": ", 1)[-1]
|
||||||
|
print(f" · {len(skipped)} skipped ({reason}): {names}")
|
||||||
|
if failed:
|
||||||
|
for feature, status in failed:
|
||||||
|
reason = status.split(": ", 1)[-1]
|
||||||
|
# Clip noisy pip stderr to keep update output legible.
|
||||||
|
if len(reason) > 200:
|
||||||
|
reason = reason[:200] + "..."
|
||||||
|
print(f" ⚠ {feature} failed to refresh: {reason}")
|
||||||
|
print(" Backends keep their previously-installed version; rerun")
|
||||||
|
print(" `hermes update` once the upstream issue is resolved.")
|
||||||
|
|
||||||
|
|
||||||
def _install_python_dependencies_with_optional_fallback(
|
def _install_python_dependencies_with_optional_fallback(
|
||||||
install_cmd_prefix: list[str],
|
install_cmd_prefix: list[str],
|
||||||
*,
|
*,
|
||||||
|
|
@ -7749,6 +7817,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||||
_install_psutil_android_compat(pip_cmd)
|
_install_psutil_android_compat(pip_cmd)
|
||||||
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
|
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
|
||||||
|
|
||||||
|
_refresh_active_lazy_features()
|
||||||
|
|
||||||
_update_node_dependencies()
|
_update_node_dependencies()
|
||||||
_build_web_ui(PROJECT_ROOT / "web")
|
_build_web_ui(PROJECT_ROOT / "web")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -226,3 +226,182 @@ class TestIsAvailable:
|
||||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",))
|
monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",))
|
||||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||||
assert ld.is_available("test.miss") is False
|
assert ld.is_available("test.miss") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Version-aware _is_satisfied (Piece B — "stale pin" detection)
|
||||||
|
#
|
||||||
|
# The original implementation returned True the moment the package name
|
||||||
|
# was importable, ignoring the spec's version range. That meant pin bumps
|
||||||
|
# in LAZY_DEPS never propagated to users who already lazy-installed the
|
||||||
|
# backend at an older version. _is_satisfied now parses the spec and
|
||||||
|
# checks the installed version against the constraint.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsSatisfiedVersionAware:
|
||||||
|
def _fake_version(self, monkeypatch, installed_versions: dict):
|
||||||
|
"""Patch importlib.metadata.version() inside lazy_deps."""
|
||||||
|
from importlib.metadata import PackageNotFoundError
|
||||||
|
|
||||||
|
def _version(pkg):
|
||||||
|
if pkg in installed_versions:
|
||||||
|
return installed_versions[pkg]
|
||||||
|
raise PackageNotFoundError(pkg)
|
||||||
|
|
||||||
|
# Patch at the import site lazy_deps uses (inside the function).
|
||||||
|
import importlib.metadata as _md
|
||||||
|
monkeypatch.setattr(_md, "version", _version)
|
||||||
|
|
||||||
|
def test_exact_pin_match_returns_true(self, monkeypatch):
|
||||||
|
self._fake_version(monkeypatch, {"honcho-ai": "2.0.1"})
|
||||||
|
assert ld._is_satisfied("honcho-ai==2.0.1") is True
|
||||||
|
|
||||||
|
def test_exact_pin_mismatch_returns_false(self, monkeypatch):
|
||||||
|
# Installed 2.0.0, spec requires 2.0.1 → False (needs upgrade).
|
||||||
|
self._fake_version(monkeypatch, {"honcho-ai": "2.0.0"})
|
||||||
|
assert ld._is_satisfied("honcho-ai==2.0.1") is False
|
||||||
|
|
||||||
|
def test_range_within_returns_true(self, monkeypatch):
|
||||||
|
self._fake_version(monkeypatch, {"slack-bolt": "1.27.0"})
|
||||||
|
assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is True
|
||||||
|
|
||||||
|
def test_range_above_returns_false(self, monkeypatch):
|
||||||
|
# Installed too new for the upper bound.
|
||||||
|
self._fake_version(monkeypatch, {"slack-bolt": "2.0.0"})
|
||||||
|
assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False
|
||||||
|
|
||||||
|
def test_range_below_returns_false(self, monkeypatch):
|
||||||
|
self._fake_version(monkeypatch, {"slack-bolt": "1.0.0"})
|
||||||
|
assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False
|
||||||
|
|
||||||
|
def test_package_not_installed_returns_false(self, monkeypatch):
|
||||||
|
self._fake_version(monkeypatch, {})
|
||||||
|
assert ld._is_satisfied("anthropic==0.86.0") is False
|
||||||
|
|
||||||
|
def test_bare_package_name_presence_is_enough(self, monkeypatch):
|
||||||
|
# No version constraint — presence alone counts as satisfied.
|
||||||
|
self._fake_version(monkeypatch, {"somepkg": "1.0.0"})
|
||||||
|
assert ld._is_satisfied("somepkg") is True
|
||||||
|
|
||||||
|
def test_extras_block_in_spec_is_stripped(self, monkeypatch):
|
||||||
|
# mautrix[encryption]==0.21.0 — the [encryption] block must not
|
||||||
|
# confuse the specifier parser.
|
||||||
|
self._fake_version(monkeypatch, {"mautrix": "0.21.0"})
|
||||||
|
assert ld._is_satisfied("mautrix[encryption]==0.21.0") is True
|
||||||
|
|
||||||
|
def test_extras_block_mismatch_returns_false(self, monkeypatch):
|
||||||
|
self._fake_version(monkeypatch, {"mautrix": "0.20.0"})
|
||||||
|
assert ld._is_satisfied("mautrix[encryption]==0.21.0") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# active_features + refresh_active_features (Piece A — hermes update wiring)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestActiveFeatures:
|
||||||
|
def test_no_packages_installed_returns_empty(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(ld, "_is_present", lambda spec: False)
|
||||||
|
assert ld.active_features() == []
|
||||||
|
|
||||||
|
def test_finds_features_with_at_least_one_package_installed(self, monkeypatch):
|
||||||
|
# Pretend only honcho-ai is installed; nothing else.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ld, "_is_present",
|
||||||
|
lambda spec: ld._pkg_name_from_spec(spec) == "honcho-ai",
|
||||||
|
)
|
||||||
|
active = ld.active_features()
|
||||||
|
assert "memory.honcho" in active
|
||||||
|
# Backends the user never enabled stay quiet.
|
||||||
|
assert "memory.hindsight" not in active
|
||||||
|
assert "platform.slack" not in active
|
||||||
|
|
||||||
|
def test_multi_package_feature_active_if_any_present(self, monkeypatch):
|
||||||
|
# platform.slack has 3 packages; only one needs to be present
|
||||||
|
# for the feature to count as active (user activated it before,
|
||||||
|
# one transitive may have been uninstalled separately).
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ld, "_is_present",
|
||||||
|
lambda spec: ld._pkg_name_from_spec(spec) == "slack-bolt",
|
||||||
|
)
|
||||||
|
assert "platform.slack" in ld.active_features()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshActiveFeatures:
|
||||||
|
def test_no_active_features_returns_empty(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(ld, "active_features", lambda: [])
|
||||||
|
assert ld.refresh_active_features() == {}
|
||||||
|
|
||||||
|
def test_already_current_is_noop(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"])
|
||||||
|
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==1.0.0",))
|
||||||
|
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True)
|
||||||
|
# If pip were called, this would fail loudly.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ld, "_venv_pip_install",
|
||||||
|
lambda *a, **kw: pytest.fail("pip should not be called"),
|
||||||
|
)
|
||||||
|
result = ld.refresh_active_features()
|
||||||
|
assert result == {"test.feat": "current"}
|
||||||
|
|
||||||
|
def test_stale_pin_triggers_reinstall(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"])
|
||||||
|
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",))
|
||||||
|
# First _is_satisfied check (in feature_missing) says no; after
|
||||||
|
# install, post-install check says yes.
|
||||||
|
states = iter([False, True])
|
||||||
|
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states))
|
||||||
|
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ld, "_venv_pip_install",
|
||||||
|
lambda specs, **kw: ld._InstallResult(True, "ok", ""),
|
||||||
|
)
|
||||||
|
result = ld.refresh_active_features()
|
||||||
|
assert result == {"test.feat": "refreshed"}
|
||||||
|
|
||||||
|
def test_install_failure_recorded_not_raised(self, monkeypatch):
|
||||||
|
# A failed refresh must NOT raise out of hermes update.
|
||||||
|
monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"])
|
||||||
|
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",))
|
||||||
|
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||||
|
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ld, "_venv_pip_install",
|
||||||
|
lambda specs, **kw: ld._InstallResult(
|
||||||
|
False, "", "ERROR: PyPI 404 quarantine"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = ld.refresh_active_features()
|
||||||
|
assert "test.feat" in result
|
||||||
|
assert result["test.feat"].startswith("failed:")
|
||||||
|
assert "404 quarantine" in result["test.feat"]
|
||||||
|
|
||||||
|
def test_lazy_installs_disabled_marked_skipped(self, monkeypatch):
|
||||||
|
# security.allow_lazy_installs=false → don't error, mark skipped
|
||||||
|
# so hermes update can render "respecting your config" message.
|
||||||
|
monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"])
|
||||||
|
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",))
|
||||||
|
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||||
|
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False)
|
||||||
|
result = ld.refresh_active_features()
|
||||||
|
assert "test.feat" in result
|
||||||
|
assert result["test.feat"].startswith("skipped:")
|
||||||
|
|
||||||
|
def test_mixed_results_returns_per_feature_status(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(ld, "active_features", lambda: ["a.ok", "b.fail"])
|
||||||
|
monkeypatch.setitem(ld.LAZY_DEPS, "a.ok", ("pkga==1.0",))
|
||||||
|
monkeypatch.setitem(ld.LAZY_DEPS, "b.fail", ("pkgb==1.0",))
|
||||||
|
# a.ok: already satisfied → "current"
|
||||||
|
# b.fail: missing + install fails → "failed:"
|
||||||
|
def fake_satisfied(spec):
|
||||||
|
return ld._pkg_name_from_spec(spec) == "pkga"
|
||||||
|
monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied)
|
||||||
|
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ld, "_venv_pip_install",
|
||||||
|
lambda specs, **kw: ld._InstallResult(False, "", "nope"),
|
||||||
|
)
|
||||||
|
result = ld.refresh_active_features()
|
||||||
|
assert result["a.ok"] == "current"
|
||||||
|
assert result["b.fail"].startswith("failed:")
|
||||||
|
|
|
||||||
|
|
@ -248,12 +248,69 @@ def _pkg_name_from_spec(spec: str) -> str:
|
||||||
return m.group(1) if m else spec
|
return m.group(1) if m else spec
|
||||||
|
|
||||||
|
|
||||||
def _is_satisfied(spec: str) -> bool:
|
def _specifier_from_spec(spec: str) -> str:
|
||||||
"""Best-effort check: is ``spec`` already satisfied in the current env?
|
"""Extract just the version-specifier portion of a pip spec.
|
||||||
|
|
||||||
We don't enforce the version range — if the package is importable
|
``"honcho-ai==2.0.1"`` → ``"==2.0.1"``
|
||||||
we assume the user knows what they're doing. This matches how the
|
``"mautrix[encryption]>=0.20,<1"`` → ``">=0.20,<1"``
|
||||||
lazy-import sites already behave.
|
``"package"`` → ``""`` (no version constraint)
|
||||||
|
"""
|
||||||
|
# Strip the package name + optional [extras] block.
|
||||||
|
m = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*(?:\[[A-Za-z0-9_,\-]+\])?", spec)
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
return spec[m.end():]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_satisfied(spec: str) -> bool:
|
||||||
|
"""Is ``spec`` already satisfied in the current env?
|
||||||
|
|
||||||
|
Checks both presence AND version. If the package is installed at a
|
||||||
|
version outside the spec's range, returns False so the caller will
|
||||||
|
upgrade/downgrade to the pinned version. This is what makes
|
||||||
|
``hermes update`` propagate pin bumps in :data:`LAZY_DEPS` to already-
|
||||||
|
installed backends instead of silently leaving stale versions in place.
|
||||||
|
|
||||||
|
If ``packaging`` is unavailable for any reason (it's a transitive of
|
||||||
|
pip so this should never happen), we fall back to a presence-only check
|
||||||
|
so we err on the side of "don't churn".
|
||||||
|
"""
|
||||||
|
pkg = _pkg_name_from_spec(spec)
|
||||||
|
try:
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
installed = version(pkg)
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
spec_tail = _specifier_from_spec(spec)
|
||||||
|
if not spec_tail:
|
||||||
|
# Bare ``"package"`` — no version constraint, presence is enough.
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
|
except ImportError:
|
||||||
|
# packaging unavailable — fall back to "installed counts as satisfied".
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Version(installed) in SpecifierSet(spec_tail)
|
||||||
|
except (InvalidSpecifier, InvalidVersion, Exception):
|
||||||
|
# Malformed spec or installed version we can't parse — don't churn.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_present(spec: str) -> bool:
|
||||||
|
"""Cheap presence-only check (package name installed at any version).
|
||||||
|
|
||||||
|
Used by :func:`active_features` to detect backends the user has
|
||||||
|
previously activated, regardless of whether the version pin moved.
|
||||||
"""
|
"""
|
||||||
pkg = _pkg_name_from_spec(spec)
|
pkg = _pkg_name_from_spec(spec)
|
||||||
try:
|
try:
|
||||||
|
|
@ -442,6 +499,57 @@ def feature_install_command(feature: str) -> Optional[str]:
|
||||||
return "uv pip install " + " ".join(repr(s) for s in specs)
|
return "uv pip install " + " ".join(repr(s) for s in specs)
|
||||||
|
|
||||||
|
|
||||||
|
def active_features() -> list[str]:
|
||||||
|
"""Return the list of features the user has ever lazy-installed.
|
||||||
|
|
||||||
|
A feature counts as "active" if at least one of its declared packages
|
||||||
|
is currently installed in the venv (presence check, ignoring version).
|
||||||
|
Features the user has never enabled stay quiet.
|
||||||
|
|
||||||
|
Used by ``hermes update`` to figure out which lazy backends need a
|
||||||
|
refresh pass when pins move in :data:`LAZY_DEPS`.
|
||||||
|
"""
|
||||||
|
active = []
|
||||||
|
for feature, specs in LAZY_DEPS.items():
|
||||||
|
if any(_is_present(s) for s in specs):
|
||||||
|
active.append(feature)
|
||||||
|
return active
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_active_features(*, prompt: bool = False) -> dict[str, str]:
|
||||||
|
"""Re-run ``ensure`` for every feature the user has previously activated.
|
||||||
|
|
||||||
|
Returns a ``{feature: status}`` map where status is one of:
|
||||||
|
``"current"`` — pins already satisfied, no install run
|
||||||
|
``"refreshed"`` — pins were stale, reinstall succeeded
|
||||||
|
``"failed: <reason>"`` — install attempt failed; caller decides
|
||||||
|
whether to surface it (we don't raise)
|
||||||
|
``"skipped: <reason>"`` — gated off (config flag, user decline)
|
||||||
|
|
||||||
|
Intended for ``hermes update``. Never raises; lazy-install failures
|
||||||
|
here must not block the rest of the update flow.
|
||||||
|
"""
|
||||||
|
results: dict[str, str] = {}
|
||||||
|
for feature in active_features():
|
||||||
|
missing = feature_missing(feature)
|
||||||
|
if not missing:
|
||||||
|
results[feature] = "current"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ensure(feature, prompt=prompt)
|
||||||
|
results[feature] = "refreshed"
|
||||||
|
except FeatureUnavailable as e:
|
||||||
|
# Distinguish "user opted out" from "install failed" so the
|
||||||
|
# update command can render the right message.
|
||||||
|
if "lazy installs disabled" in str(e) or "declined" in str(e):
|
||||||
|
results[feature] = f"skipped: {e.reason}"
|
||||||
|
else:
|
||||||
|
results[feature] = f"failed: {e.reason}"
|
||||||
|
except Exception as e:
|
||||||
|
results[feature] = f"failed: {e}"
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def ensure_and_bind(
|
def ensure_and_bind(
|
||||||
feature: str,
|
feature: str,
|
||||||
importer: Callable[[], dict[str, Any]],
|
importer: Callable[[], dict[str, Any]],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue