mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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.
603 lines
23 KiB
Python
603 lines
23 KiB
Python
"""
|
|
Lazy dependency installer for opt-in Hermes Agent backends.
|
|
|
|
Many Hermes features (Mistral TTS, ElevenLabs TTS, Honcho memory, Bedrock,
|
|
Slack, Matrix, etc.) require Python packages that not every user needs. The
|
|
historical approach was to bundle them all under ``pyproject.toml`` extras
|
|
(``hermes-agent[all]``) and install them eagerly at setup time. That has
|
|
two problems:
|
|
|
|
1. **Fragility.** When one extra's transitive dependency becomes
|
|
unavailable on PyPI (quarantined for malware, yanked, broken upload),
|
|
the *entire* ``[all]`` resolve fails and fresh installs silently fall
|
|
back to a stripped tier — losing 10+ unrelated extras at once.
|
|
|
|
2. **Bloat.** A user who only ever talks to one provider pulls hundreds
|
|
of packages they will never import.
|
|
|
|
The lazy-install pattern fixes both. Backends call :func:`ensure` at the
|
|
top of their first-import path. If the deps are missing, ``ensure`` checks
|
|
the ``security.allow_lazy_installs`` config flag (default true) and runs
|
|
a venv-scoped pip install. If the user has explicitly disabled lazy
|
|
installs, ``ensure`` raises :class:`FeatureUnavailable` with a clear
|
|
remediation hint pointing at ``hermes tools`` or the manual pip command.
|
|
|
|
Security model:
|
|
|
|
* **Venv-scoped only.** Installs target ``sys.executable`` in the active
|
|
venv. We never touch the system Python.
|
|
* **PyPI by package name only.** Specs may be ``"package>=1.0,<2"`` etc.
|
|
We do NOT support ``--index-url`` overrides, ``git+https://``, file:
|
|
paths, or any other input that could be hijacked by a malicious config.
|
|
* **Allowlist.** Only specs that appear in :data:`LAZY_DEPS` can be
|
|
installed via this path. A typo in feature name doesn't get the user
|
|
install-anything semantics.
|
|
* **Opt-out.** Setting ``security.allow_lazy_installs: false`` in
|
|
``config.yaml`` disables runtime installs. Users in restricted networks
|
|
or strict security postures can pin themselves to whatever was installed
|
|
at setup time.
|
|
* **Offline detection.** If the install fails (offline, mirror down,
|
|
PyPI 404 / quarantine), we surface the failure as
|
|
:class:`FeatureUnavailable` with the actual pip stderr — no silent
|
|
retries, no caching of bad state.
|
|
|
|
Adding a new backend:
|
|
|
|
1. Add an entry to :data:`LAZY_DEPS` with the package specs.
|
|
2. At the top of the backend module's import path, call
|
|
``ensure("feature.name")`` inside a try/except that converts
|
|
:class:`FeatureUnavailable` to a useful runtime error.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Allowlist of lazy-installable backends.
|
|
#
|
|
# Keys are dot-separated feature names ("namespace.backend"). Values are
|
|
# tuples of pip-installable specs that match the corresponding extra in
|
|
# pyproject.toml. The framework enforces that only specs from this map
|
|
# can flow into the pip install command.
|
|
# =============================================================================
|
|
|
|
|
|
LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
|
# ─── Inference providers ───────────────────────────────────────────────
|
|
# Native Anthropic SDK — needed when provider=anthropic (not via
|
|
# OpenRouter / aggregators which use the openai SDK).
|
|
"provider.anthropic": ("anthropic==0.86.0",),
|
|
# AWS Bedrock provider
|
|
"provider.bedrock": ("boto3==1.42.89",),
|
|
|
|
# ─── Web search backends ───────────────────────────────────────────────
|
|
"search.exa": ("exa-py==2.10.2",),
|
|
"search.firecrawl": ("firecrawl-py==4.17.0",),
|
|
"search.parallel": ("parallel-web==0.4.2",),
|
|
|
|
# ─── TTS providers ─────────────────────────────────────────────────────
|
|
# Pinned to exact versions to match pyproject.toml's no-ranges policy
|
|
# (see comment at top of [project.dependencies]). When bumping, update
|
|
# both this map AND the corresponding extra in pyproject.toml.
|
|
#
|
|
# NOTE: tts.mistral / stt.mistral entries are intentionally absent —
|
|
# the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini
|
|
# Shai-Hulud worm). Re-add when PyPI restores a clean release; see
|
|
# comment in pyproject.toml above the (removed) `mistral` extra for
|
|
# the full restoration checklist.
|
|
"tts.edge": ("edge-tts==7.2.7",),
|
|
"tts.elevenlabs": ("elevenlabs==1.59.0",),
|
|
|
|
# ─── Speech-to-text providers ──────────────────────────────────────────
|
|
"stt.faster_whisper": (
|
|
"faster-whisper==1.2.1",
|
|
"sounddevice==0.5.5",
|
|
"numpy==2.4.3",
|
|
),
|
|
|
|
# ─── Image generation backends ─────────────────────────────────────────
|
|
"image.fal": ("fal-client==0.13.1",),
|
|
|
|
# ─── Memory providers ──────────────────────────────────────────────────
|
|
"memory.honcho": ("honcho-ai==2.0.1",),
|
|
"memory.hindsight": ("hindsight-client==0.6.1",),
|
|
|
|
# ─── Messaging platforms (lazy-installable on demand) ──────────────────
|
|
"platform.telegram": ("python-telegram-bot[webhooks]==22.6",),
|
|
"platform.discord": ("discord.py[voice]==2.7.1",),
|
|
"platform.slack": (
|
|
"slack-bolt==1.27.0",
|
|
"slack-sdk==3.40.1",
|
|
"aiohttp==3.13.3",
|
|
),
|
|
"platform.matrix": (
|
|
"mautrix[encryption]==0.21.0",
|
|
"Markdown==3.10.2",
|
|
"aiosqlite==0.22.1",
|
|
"asyncpg==0.31.0",
|
|
"aiohttp-socks==0.11.0",
|
|
),
|
|
"platform.dingtalk": (
|
|
"dingtalk-stream==0.24.3",
|
|
"alibabacloud-dingtalk==2.2.42",
|
|
"qrcode==7.4.2",
|
|
),
|
|
"platform.feishu": (
|
|
"lark-oapi==1.5.3",
|
|
"qrcode==7.4.2",
|
|
),
|
|
|
|
# ─── Terminal backends ─────────────────────────────────────────────────
|
|
"terminal.modal": ("modal==1.3.4",),
|
|
"terminal.daytona": ("daytona==0.155.0",),
|
|
"terminal.vercel": ("vercel==0.5.7",),
|
|
|
|
# ─── Skills ────────────────────────────────────────────────────────────
|
|
"skill.google_workspace": (
|
|
"google-api-python-client==2.194.0",
|
|
"google-auth-oauthlib==1.3.1",
|
|
"google-auth-httplib2==0.3.1",
|
|
),
|
|
"skill.youtube": ("youtube-transcript-api==1.2.4",),
|
|
|
|
# ─── Tools ─────────────────────────────────────────────────────────────
|
|
# ACP adapter (VS Code / Zed / JetBrains integration)
|
|
"tool.acp": ("agent-client-protocol==0.9.0",),
|
|
# Dashboard (`hermes dashboard`)
|
|
"tool.dashboard": (
|
|
"fastapi==0.133.1",
|
|
"uvicorn[standard]==0.41.0",
|
|
),
|
|
}
|
|
|
|
|
|
# Conservative regex for spec validation — package name plus optional
|
|
# version range. Reject anything that looks like a URL, file path, or shell
|
|
# metacharacter.
|
|
_SAFE_SPEC = re.compile(
|
|
r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*" # package name
|
|
r"(?:\[[A-Za-z0-9_,\-]+\])?" # optional [extras]
|
|
r"(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?" # optional version specifier
|
|
r"$"
|
|
)
|
|
|
|
|
|
class FeatureUnavailable(RuntimeError):
|
|
"""A lazily-installable feature is missing and cannot be made available.
|
|
|
|
Either the deps were never installed and the user has disabled lazy
|
|
installs, or the install attempt failed.
|
|
"""
|
|
|
|
def __init__(self, feature: str, missing: tuple[str, ...], reason: str):
|
|
self.feature = feature
|
|
self.missing = missing
|
|
self.reason = reason
|
|
super().__init__(self._format())
|
|
|
|
def _format(self) -> str:
|
|
spec_list = " ".join(repr(s) for s in self.missing)
|
|
return (
|
|
f"Feature {self.feature!r} unavailable: {self.reason}. "
|
|
f"To enable manually: uv pip install {spec_list} "
|
|
f"(or: pip install {spec_list})."
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _InstallResult:
|
|
success: bool
|
|
stdout: str
|
|
stderr: str
|
|
|
|
|
|
# =============================================================================
|
|
# Internals
|
|
# =============================================================================
|
|
|
|
|
|
def _allow_lazy_installs() -> bool:
|
|
"""Return the ``security.allow_lazy_installs`` config flag.
|
|
|
|
Defaults to True. If config is unreadable we fail open (allow), because
|
|
refusing to install would lock people out of their own backends; the
|
|
decision to block is an explicit user opt-in.
|
|
"""
|
|
if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1":
|
|
return False
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
cfg = load_config()
|
|
except Exception:
|
|
return True
|
|
sec = cfg.get("security") or {}
|
|
val = sec.get("allow_lazy_installs", True)
|
|
return bool(val)
|
|
|
|
|
|
def _spec_is_safe(spec: str) -> bool:
|
|
"""Reject pip specs that contain URLs, paths, or shell metacharacters."""
|
|
if not spec or len(spec) > 200:
|
|
return False
|
|
if any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\")):
|
|
return False
|
|
if spec.startswith(("-", "/", ".")) or "://" in spec or "@" in spec:
|
|
return False
|
|
return bool(_SAFE_SPEC.match(spec))
|
|
|
|
|
|
def _pkg_name_from_spec(spec: str) -> str:
|
|
"""Extract the bare package name from a pip spec.
|
|
|
|
``"slack-bolt>=1.18.0,<2"`` → ``"slack-bolt"``
|
|
``"mautrix[encryption]>=0.20"`` → ``"mautrix"``
|
|
"""
|
|
m = re.match(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)", spec)
|
|
return m.group(1) if m else spec
|
|
|
|
|
|
def _specifier_from_spec(spec: str) -> str:
|
|
"""Extract just the version-specifier portion of a pip spec.
|
|
|
|
``"honcho-ai==2.0.1"`` → ``"==2.0.1"``
|
|
``"mautrix[encryption]>=0.20,<1"`` → ``">=0.20,<1"``
|
|
``"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)
|
|
try:
|
|
from importlib.metadata import PackageNotFoundError, version
|
|
except ImportError:
|
|
return False
|
|
try:
|
|
version(pkg)
|
|
return True
|
|
except PackageNotFoundError:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult:
|
|
"""Install ``specs`` into the active venv using uv → pip → ensurepip ladder.
|
|
|
|
Mirrors the strategy in ``hermes_cli.tools_config._pip_install`` but
|
|
kept independent here so this module has no CLI dependency.
|
|
"""
|
|
if not specs:
|
|
return _InstallResult(True, "", "")
|
|
|
|
venv_root = Path(sys.executable).parent.parent
|
|
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
|
|
|
|
# Tier 1: uv (preferred — fast, doesn't need pip in the venv)
|
|
uv_bin = shutil.which("uv")
|
|
if uv_bin:
|
|
try:
|
|
r = subprocess.run(
|
|
[uv_bin, "pip", "install", *specs],
|
|
capture_output=True, text=True, timeout=timeout, env=uv_env,
|
|
)
|
|
if r.returncode == 0:
|
|
return _InstallResult(True, r.stdout or "", r.stderr or "")
|
|
logger.debug("uv pip install failed: %s", r.stderr)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
|
logger.debug("uv invocation failed: %s", e)
|
|
|
|
# Tier 2: python -m pip (with ensurepip bootstrap if needed)
|
|
pip_cmd = [sys.executable, "-m", "pip"]
|
|
try:
|
|
probe = subprocess.run(
|
|
pip_cmd + ["--version"],
|
|
capture_output=True, text=True, timeout=15,
|
|
)
|
|
if probe.returncode != 0:
|
|
raise FileNotFoundError("pip not in venv")
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
try:
|
|
subprocess.run(
|
|
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
|
capture_output=True, text=True, timeout=120, check=True,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
return _InstallResult(False, "",
|
|
f"pip not available and ensurepip failed: {e}")
|
|
|
|
try:
|
|
r = subprocess.run(
|
|
pip_cmd + ["install", *specs],
|
|
capture_output=True, text=True, timeout=timeout,
|
|
)
|
|
return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "")
|
|
except subprocess.TimeoutExpired as e:
|
|
return _InstallResult(False, "", f"pip install timed out: {e}")
|
|
except Exception as e:
|
|
return _InstallResult(False, "", f"pip install failed: {e}")
|
|
|
|
|
|
# =============================================================================
|
|
# Public API
|
|
# =============================================================================
|
|
|
|
|
|
def feature_specs(feature: str) -> tuple[str, ...]:
|
|
"""Return the registered specs for a feature, or raise KeyError."""
|
|
if feature not in LAZY_DEPS:
|
|
raise KeyError(f"Unknown lazy feature: {feature!r}")
|
|
return LAZY_DEPS[feature]
|
|
|
|
|
|
def feature_missing(feature: str) -> tuple[str, ...]:
|
|
"""Return the subset of specs for ``feature`` not currently installed."""
|
|
return tuple(s for s in feature_specs(feature) if not _is_satisfied(s))
|
|
|
|
|
|
def ensure(feature: str, *, prompt: bool = True) -> None:
|
|
"""Make sure all packages for ``feature`` are importable.
|
|
|
|
If they're missing, attempts to install them in the active venv. Raises
|
|
:class:`FeatureUnavailable` if the user has disabled lazy installs or
|
|
if the install attempt fails.
|
|
|
|
``prompt``: when True (default) and stdin is a TTY, asks the user to
|
|
confirm before installing. Non-interactive callers (gateway, cron,
|
|
batch) get prompt=False and skip the confirmation — config flag is
|
|
the gate in that case.
|
|
"""
|
|
if feature not in LAZY_DEPS:
|
|
raise FeatureUnavailable(
|
|
feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist"
|
|
)
|
|
|
|
missing = feature_missing(feature)
|
|
if not missing:
|
|
return
|
|
|
|
# Validate every spec against the allowlist + safety regex. Belt and
|
|
# braces — the keys-in-LAZY_DEPS check above already constrains this.
|
|
for spec in missing:
|
|
if not _spec_is_safe(spec):
|
|
raise FeatureUnavailable(
|
|
feature, missing,
|
|
f"refusing to install unsafe spec {spec!r}"
|
|
)
|
|
|
|
if not _allow_lazy_installs():
|
|
raise FeatureUnavailable(
|
|
feature, missing,
|
|
"lazy installs disabled (security.allow_lazy_installs=false)"
|
|
)
|
|
|
|
if prompt and sys.stdin.isatty() and sys.stdout.isatty():
|
|
spec_list = ", ".join(missing)
|
|
try:
|
|
answer = input(
|
|
f"\nFeature {feature!r} requires: {spec_list}\n"
|
|
f"Install into the active venv now? [Y/n] "
|
|
).strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
answer = "n"
|
|
if answer and answer not in ("y", "yes"):
|
|
raise FeatureUnavailable(
|
|
feature, missing, "user declined install at prompt"
|
|
)
|
|
|
|
logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature)
|
|
result = _venv_pip_install(missing)
|
|
if not result.success:
|
|
# Surface the actual pip error so the user can debug PyPI-side
|
|
# issues (404 quarantine, network down, etc.).
|
|
snippet = (result.stderr or result.stdout or "").strip()
|
|
if snippet:
|
|
# Clip to a readable size — pip can dump pages of resolution traces.
|
|
snippet = snippet[-2000:]
|
|
raise FeatureUnavailable(
|
|
feature, missing,
|
|
f"pip install failed: {snippet or 'no error output'}"
|
|
)
|
|
|
|
# Verify post-install. importlib.metadata caches per-process, so if we
|
|
# just installed something the cache may not see it without a refresh.
|
|
try:
|
|
import importlib.metadata as _md
|
|
if hasattr(_md, "_cache_clear"):
|
|
_md._cache_clear() # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
still_missing = feature_missing(feature)
|
|
if still_missing:
|
|
raise FeatureUnavailable(
|
|
feature, still_missing,
|
|
"install reported success but packages still not importable "
|
|
"(may require Python restart)"
|
|
)
|
|
|
|
logger.info("Lazy install complete for feature %r", feature)
|
|
|
|
|
|
def is_available(feature: str) -> bool:
|
|
"""Return True if the feature's deps are already satisfied."""
|
|
if feature not in LAZY_DEPS:
|
|
return False
|
|
return not feature_missing(feature)
|
|
|
|
|
|
def feature_install_command(feature: str) -> Optional[str]:
|
|
"""Return the ``pip install`` command a user could run manually, or None."""
|
|
if feature not in LAZY_DEPS:
|
|
return None
|
|
specs = LAZY_DEPS[feature]
|
|
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(
|
|
feature: str,
|
|
importer: Callable[[], dict[str, Any]],
|
|
target_globals: dict,
|
|
*,
|
|
prompt: bool = False,
|
|
) -> bool:
|
|
"""Ensure a feature is installed, then rebind names into the caller's globals.
|
|
|
|
Combines :func:`ensure` with a post-install import step that rebinds
|
|
module-level names. This eliminates the error-prone pattern of manually
|
|
listing every global that needs updating after lazy-install.
|
|
|
|
``importer`` is a zero-arg callable that returns a dict of
|
|
``{name: value}`` for all symbols the caller needs rebound. It is called
|
|
only after :func:`ensure` succeeds (or if the packages are already
|
|
installed).
|
|
|
|
Returns True on success, False if deps couldn't be installed or imported.
|
|
|
|
Example usage in a platform adapter::
|
|
|
|
def check_slack_requirements() -> bool:
|
|
if SLACK_AVAILABLE:
|
|
return True
|
|
def _import():
|
|
from slack_bolt.async_app import AsyncApp
|
|
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
|
|
from slack_sdk.web.async_client import AsyncWebClient
|
|
import aiohttp
|
|
return {
|
|
"AsyncApp": AsyncApp,
|
|
"AsyncSocketModeHandler": AsyncSocketModeHandler,
|
|
"AsyncWebClient": AsyncWebClient,
|
|
"aiohttp": aiohttp,
|
|
"SLACK_AVAILABLE": True,
|
|
}
|
|
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
|
|
"""
|
|
try:
|
|
ensure(feature, prompt=prompt)
|
|
except (FeatureUnavailable, Exception):
|
|
return False
|
|
|
|
try:
|
|
bindings = importer()
|
|
except ImportError:
|
|
return False
|
|
|
|
target_globals.update(bindings)
|
|
return True
|