authz_mixin, browser_control_broker, delivery, delivery_ledger, display_config, drain_control, hosted_room_links/peer/policy_checkpoint, hosted_rooms, platform_registry, relay/__init__, relay/ws_transport, run.py and slash_commands.py (comments), session_context, session_state, streaming_tts_consumer, turn_lease and small modules. - HostedRoomPolicyCheckpoint._apply_event -> per-kind handler table - WebSocketRelayTransport._handle_frame -> frame-handler table - GatewayAuthorizationMixin: unified adapter setting/flag/extra readers - dead symbols removed (verified zero references): RoomLinkProbe/select_room_link, relay_bot_username, is_restart_loop_tripped, debug_rows, DeadTargetRegistry.all_dead, BrowserControlBroker.detach_owner/_prune_tickets, StreamingTTSConsumer.started/_enqueue_done/ _iter_stream_chunks/_next_stream_chunk, RecoverableHandleCache.status_for, _auth_env, _copy_default_catalog, _parse_timestamp_prefix, _present_* helpers, _send_result_error_kind, _truthy_env, SessionFieldView/TurnLeaseTokenView dunder shims, and their orphaned tests. - lost WHY/invariant text from the earlier compaction restored compactly (541 hunks audited)
526 lines
23 KiB
Python
526 lines
23 KiB
Python
"""
|
|
Platform Adapter Registry
|
|
|
|
Platform adapters (built-in and plugin) self-register here so the gateway can
|
|
discover and instantiate them without hardcoded if/elif chains. Plugins
|
|
register via ``PluginContext.register_platform()``; ``GatewayRunner
|
|
._create_adapter()`` consults the registry first and falls back to the legacy
|
|
built-in path.
|
|
|
|
Usage (plugin side)::
|
|
|
|
platform_registry.register(PlatformEntry(
|
|
name="irc", label="IRC",
|
|
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
|
check_fn=check_requirements,
|
|
validate_config=lambda cfg: bool(cfg.extra.get("server")),
|
|
required_env=["IRC_SERVER"], install_hint="pip install irc",
|
|
))
|
|
|
|
Usage (gateway side)::
|
|
|
|
adapter = platform_registry.create_adapter("irc", platform_config)
|
|
"""
|
|
|
|
import logging
|
|
import sys
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Awaitable, Callable, Optional
|
|
|
|
from hermes_constants import hermes_home_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_LoadKey = tuple[Optional[str], str]
|
|
_Loader = Callable[[], None]
|
|
|
|
|
|
def _plugin_scope_from_callable(callback: Callable) -> Optional[str]:
|
|
"""Infer a plugin profile from code registered outside PluginContext."""
|
|
try:
|
|
from tools.registry import registry as tool_registry
|
|
|
|
return tool_registry.plugin_scope_for_callable(callback)
|
|
except (ImportError, AttributeError):
|
|
return None
|
|
|
|
|
|
def _caller_plugin_scope() -> Optional[str]:
|
|
try:
|
|
module_name = sys._getframe(2).f_globals.get("__name__", "") or ""
|
|
except Exception:
|
|
return None
|
|
return _plugin_scope_from_callable(
|
|
type("_Caller", (), {"__module__": module_name})
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PlatformEntry:
|
|
"""Metadata and factory for a single platform adapter."""
|
|
|
|
# Identifier used in config.yaml (e.g. "irc") and human-readable label.
|
|
name: str
|
|
label: str
|
|
|
|
# Receives a PlatformConfig, returns an adapter instance. A factory (not a
|
|
# bare class) lets plugins do custom init / wrapping.
|
|
adapter_factory: Callable[[Any], Any]
|
|
|
|
# PASSIVE dependency probe: True when deps are importable RIGHT NOW. Must
|
|
# be side-effect free -- it runs from status displays (`hermes setup`,
|
|
# `hermes status`, dashboard readiness) and the config enablement pass,
|
|
# none of which may pip-install. Install logic belongs in ensure_deps_fn.
|
|
check_fn: Callable[[], bool]
|
|
|
|
# Optional config check. None = skip and let connect() fail descriptively.
|
|
validate_config: Optional[Callable[[Any], bool]] = None
|
|
|
|
# ACTIVE dependency installer: make deps importable (pip / lazy_deps),
|
|
# returning True on success. Called by ``create_adapter()`` only when
|
|
# ``check_fn`` is False -- the one moment the user has the platform
|
|
# enabled+configured and the gateway is about to connect it. None = a
|
|
# False ``check_fn`` is a hard block (correct for platforms without
|
|
# optional deps). The passive/active split exists because a single field
|
|
# either pip-installed from every status display or never installed at all.
|
|
ensure_deps_fn: Optional[Callable[[], bool]] = None
|
|
|
|
# Is the platform connected/enabled for this PlatformConfig? Used by
|
|
# ``GatewayConfig.get_connected_platforms()`` and setup UI status; None
|
|
# falls back to ``validate_config`` or ``check_fn``.
|
|
is_connected: Optional[Callable[[Any], bool]] = None
|
|
|
|
# Env vars this platform needs (``hermes setup`` display) and the hint
|
|
# shown when check_fn returns False.
|
|
required_env: list = field(default_factory=list)
|
|
install_hint: str = ""
|
|
|
|
# Interactive setup ``() -> None``. None falls back to
|
|
# _setup_standard_platform (needs token_var + vars) or a generic env display.
|
|
setup_fn: Optional[Callable[[], None]] = None
|
|
|
|
# "builtin" or "plugin"; plugin_name is the owning manifest (empty for
|
|
# built-ins) so ``hermes gateway setup`` can auto-enable the plugin.
|
|
source: str = "plugin"
|
|
plugin_name: str = ""
|
|
|
|
# Auth env var names for _is_user_authorized: comma-separated allowed user
|
|
# IDs, and a truthy "allow everyone" switch.
|
|
allowed_users_env: str = ""
|
|
allow_all_env: str = ""
|
|
|
|
# Max message length for smart-chunking; 0 = no limit.
|
|
max_message_length: int = 0
|
|
|
|
# If True, session descriptions redact PII (phone numbers, etc.).
|
|
pii_safe: bool = False
|
|
|
|
# Emoji for CLI/gateway display.
|
|
emoji: str = "🔌"
|
|
|
|
# Whether /update may be issued from this platform (_UPDATE_ALLOWED_PLATFORMS).
|
|
allow_update_command: bool = True
|
|
|
|
# Platform hint injected into the system prompt; empty = none.
|
|
platform_hint: str = ""
|
|
|
|
# Env-driven auto-configuration ``() -> Optional[dict]``: returns
|
|
# ``PlatformConfig.extra`` fields to seed when the platform is auto-enabled.
|
|
# Runs during ``_apply_env_overrides`` BEFORE the adapter is constructed so
|
|
# ``gateway status`` reflects env-only config. None/empty dict = skip.
|
|
env_enablement_fn: Optional[Callable[[], Optional[dict]]] = None
|
|
|
|
# YAML->env bridge ``(yaml_cfg, platform_cfg) -> Optional[dict]``: lets a
|
|
# plugin own its config.yaml translation instead of core gateway/config.py.
|
|
# Called from ``load_gateway_config()`` after the generic shared-key loop and
|
|
# before ``_apply_env_overrides``. May mutate ``os.environ`` (guard with
|
|
# ``not os.getenv(...)`` to keep env > YAML precedence); the returned dict is
|
|
# merged into ``PlatformConfig.extra``. Exceptions are logged at debug.
|
|
# Full contract: website/docs/developer-guide/adding-platform-adapters.md.
|
|
apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None
|
|
|
|
# Home-channel env var (e.g. "IRC_HOME_CHANNEL"). When set, cron.scheduler
|
|
# accepts ``deliver=<name>`` and reads it for the default chat/room ID.
|
|
cron_deliver_env_var: str = ""
|
|
|
|
# Target parsing ``(target_ref) -> Optional[(chat_id, thread_id)]``, run by
|
|
# ``tools/send_message_tool._parse_target_ref`` before channel-directory
|
|
# fallback so plugins can declare native target syntax
|
|
# (e.g. ``fmsg:@alice@example.com``). None result = continue to directory
|
|
# resolution; no opaque fallback is applied.
|
|
parse_target_ref_fn: Optional[Callable[[str], Optional[tuple[str, Optional[str]]]]] = None
|
|
|
|
# Validation after parsing/normalization or directory resolution: True to
|
|
# accept, False to reject, or a non-empty string to reject with diagnostic.
|
|
validate_target_ref_fn: Optional[Callable[[str], bool | str]] = None
|
|
|
|
# Whole-request delivery handler ``(args, normalized_chat_id, platform_name,
|
|
# pconfig)``, sync or async. Prefer standalone_sender_fn when the standard
|
|
# send contract suffices.
|
|
send_message_handler: Optional[Callable[[dict, str, str, Any], Any]] = None
|
|
|
|
# Out-of-process sender used by ``_send_via_adapter`` when cron runs apart
|
|
# from the gateway and the in-process adapter weakref is None::
|
|
# async (pconfig, chat_id, message, *, thread_id=None,
|
|
# media_files=None, force_document=False) -> dict
|
|
# Returns ``{"success": True, "message_id": ...}`` or ``{"error": str}``.
|
|
# Without it, plugin platforms cannot be cron ``deliver=`` targets when the
|
|
# gateway is not co-resident.
|
|
standalone_sender_fn: Optional[Callable[..., Awaitable[dict]]] = None
|
|
|
|
|
|
class PlatformRegistry:
|
|
"""Central registry of platform adapters.
|
|
|
|
Registrations are serialized, and concurrent lazy lookups share an
|
|
in-flight event while the loader runs outside the registry lock.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.RLock()
|
|
# Process-global registrations (e.g. the built-in relay).
|
|
self._entries: dict[str, PlatformEntry] = {}
|
|
# Plugin adapters are isolated per resolved HERMES_HOME and overlay the
|
|
# process-global entries for lookups in that profile's runtime scope.
|
|
self._scoped_entries: dict[str, dict[str, PlatformEntry]] = {}
|
|
# Deferred loaders: name -> zero-arg callable that imports the owning
|
|
# plugin module (which calls register()). Adapter modules import heavy
|
|
# SDKs at module level; eagerly loading ~20 bundled platforms added
|
|
# seconds to every `hermes` invocation, so the real import happens only
|
|
# when a lookup actually asks for that platform.
|
|
self._deferred: dict[str, _Loader] = {}
|
|
self._scoped_deferred: dict[str, dict[str, _Loader]] = {}
|
|
self._inflight: dict[_LoadKey, threading.Event] = {}
|
|
self._inflight_loaders: dict[_LoadKey, _Loader] = {}
|
|
self._inflight_owners: dict[_LoadKey, int] = {}
|
|
self._cancelled_inflight: set[_LoadKey] = set()
|
|
# A failed loader is no longer discoverable, but its identity remains
|
|
# until ownership teardown can CAS-restore the displaced predecessor.
|
|
self._consumed_loaders: dict[_LoadKey, _Loader] = {}
|
|
|
|
@staticmethod
|
|
def current_scope_key() -> str:
|
|
return hermes_home_key()
|
|
|
|
def _scope_maps(
|
|
self, scope: Optional[str], *, create: bool = False
|
|
) -> tuple[dict[str, PlatformEntry], dict[str, _Loader]]:
|
|
if scope is None:
|
|
return self._entries, self._deferred
|
|
if create:
|
|
return self._scoped_entries.setdefault(scope, {}), self._scoped_deferred.setdefault(scope, {})
|
|
return self._scoped_entries.get(scope, {}), self._scoped_deferred.get(scope, {})
|
|
|
|
def _registration_state(
|
|
self, scope: Optional[str], name: str, *, create: bool = False
|
|
) -> tuple[Optional[PlatformEntry], Optional[_Loader]]:
|
|
"""(entry, loader) for *name*; the loader falls back to in-flight, then consumed."""
|
|
entries, deferred = self._scope_maps(scope, create=create)
|
|
entry = entries.get(name)
|
|
loader = deferred.get(name)
|
|
if entry is None and loader is None:
|
|
loader = self._inflight_loaders.get((scope, name))
|
|
if entry is None and loader is None:
|
|
loader = self._consumed_loaders.get((scope, name))
|
|
return entry, loader
|
|
|
|
def _prune_scope(self, scope: Optional[str]) -> None:
|
|
if scope is None:
|
|
return
|
|
if not self._scoped_entries.get(scope):
|
|
self._scoped_entries.pop(scope, None)
|
|
if not self._scoped_deferred.get(scope):
|
|
self._scoped_deferred.pop(scope, None)
|
|
|
|
# -- deferred loading ----------------------------------------------------
|
|
|
|
def register_deferred(self, name: str, loader: _Loader, *, scope: Optional[str] = None) -> None:
|
|
"""Register a lazy loader for a platform that hasn't been imported yet.
|
|
|
|
*loader* imports the owning plugin module, which must call
|
|
:meth:`register` for *name*. It runs at most once, on first lookup
|
|
(or full materialization). A concrete registration takes precedence
|
|
and drops the loader.
|
|
"""
|
|
with self._lock:
|
|
entries, deferred = self._scope_maps(scope, create=True)
|
|
self._consumed_loaders.pop((scope, name), None)
|
|
if name not in entries:
|
|
deferred[name] = loader
|
|
|
|
def snapshot_registration(
|
|
self, name: str, *, scope: Optional[str] = None
|
|
) -> tuple[Optional[PlatformEntry], Optional[_Loader]]:
|
|
"""Concrete and deferred state for *name* without resolving it.
|
|
|
|
Lets the plugin ledger restore a deferred loader displaced by a concrete
|
|
registration without importing the displaced adapter as a side effect.
|
|
"""
|
|
with self._lock:
|
|
return self._registration_state(scope, name)
|
|
|
|
def restore_registration(
|
|
self,
|
|
name: str,
|
|
current: tuple[Optional[PlatformEntry], Optional[_Loader]],
|
|
previous: tuple[Optional[PlatformEntry], Optional[_Loader]],
|
|
*,
|
|
scope: Optional[str] = None,
|
|
) -> bool:
|
|
"""Restore a registration if its full state is still *current* (CAS).
|
|
|
|
Identity checks protect a later registration from removal while letting
|
|
an unloaded override reveal what it displaced. Deferred loaders are
|
|
part of the state because bundled platform plugins load lazily.
|
|
"""
|
|
with self._lock:
|
|
entry, loader = self._registration_state(scope, name, create=True)
|
|
if entry is not current[0] or loader is not current[1]:
|
|
return False
|
|
entries, deferred = self._scope_maps(scope)
|
|
load_key = (scope, name)
|
|
previous_entry, previous_loader = previous
|
|
if previous_entry is None:
|
|
entries.pop(name, None)
|
|
else:
|
|
entries[name] = previous_entry
|
|
if previous_loader is None:
|
|
deferred.pop(name, None)
|
|
else:
|
|
deferred[name] = previous_loader
|
|
if load_key in self._inflight:
|
|
self._cancelled_inflight.add(load_key)
|
|
self._consumed_loaders.pop(load_key, None)
|
|
self._prune_scope(scope)
|
|
return True
|
|
|
|
def _resolve(self, name: str, scope: Optional[str] = None) -> None:
|
|
"""Run the deferred loader for *name* if one is pending."""
|
|
loader: Optional[_Loader] = None
|
|
event: Optional[threading.Event] = None
|
|
load_key: _LoadKey
|
|
is_loader = False
|
|
with self._lock:
|
|
active_scope = scope or self.current_scope_key()
|
|
entries, deferred = self._scope_maps(active_scope)
|
|
scoped_key = (active_scope, name)
|
|
global_key = (None, name)
|
|
event = self._inflight.get(scoped_key)
|
|
load_key = scoped_key
|
|
if event is None and name not in entries:
|
|
loader = deferred.pop(name, None)
|
|
if event is None and loader is None and name not in entries:
|
|
event = self._inflight.get(global_key)
|
|
load_key = global_key
|
|
if event is None and loader is None and name not in entries:
|
|
loader = self._deferred.pop(name, None)
|
|
load_key = global_key
|
|
if event is None and loader is not None:
|
|
event = threading.Event()
|
|
self._inflight[load_key] = event
|
|
self._inflight_loaders[load_key] = loader
|
|
self._inflight_owners[load_key] = threading.get_ident()
|
|
is_loader = True
|
|
if event is None:
|
|
return
|
|
if not is_loader and self._inflight_owners.get(load_key) == threading.get_ident():
|
|
logger.warning("Deferred platform '%s' recursively requested while loading", name)
|
|
return
|
|
|
|
if not is_loader:
|
|
event.wait()
|
|
# Teardown may have restored an older deferred generation while
|
|
# cancelling the one we waited for; resolve that predecessor now
|
|
# instead of returning a one-shot false negative.
|
|
self._resolve(name, active_scope)
|
|
return
|
|
|
|
try:
|
|
loader()
|
|
except Exception as e:
|
|
logger.warning("Deferred load of platform '%s' failed: %s", name, e, exc_info=True)
|
|
finally:
|
|
with self._lock:
|
|
was_cancelled = load_key in self._cancelled_inflight
|
|
entries, deferred = self._scope_maps(load_key[0])
|
|
if not was_cancelled and name not in entries and name not in deferred:
|
|
self._consumed_loaders[load_key] = loader
|
|
self._inflight.pop(load_key, None)
|
|
self._inflight_loaders.pop(load_key, None)
|
|
self._inflight_owners.pop(load_key, None)
|
|
self._cancelled_inflight.discard(load_key)
|
|
event.set()
|
|
if was_cancelled:
|
|
self._resolve(name, active_scope)
|
|
|
|
def is_deferred_load_cancelled(self, name: str, *, scope: Optional[str] = None) -> bool:
|
|
"""Return whether ownership teardown cancelled an in-flight loader."""
|
|
with self._lock:
|
|
return (scope, name) in self._cancelled_inflight
|
|
|
|
def _resolve_all(self) -> None:
|
|
"""Run every pending deferred loader.
|
|
|
|
Only the iterate-all accessors (``all_entries``/``plugin_entries``) call
|
|
this, from paths that genuinely need every adapter (gateway startup,
|
|
``hermes setup``/``gateway status``, channel directory). CLI chat never
|
|
iterates the full set.
|
|
"""
|
|
active_scope = self.current_scope_key()
|
|
with self._lock:
|
|
_entries, scoped_deferred = self._scope_maps(active_scope)
|
|
scoped_names = set(scoped_deferred)
|
|
global_names = set(self._deferred)
|
|
for inflight_scope, name in self._inflight:
|
|
if inflight_scope == active_scope:
|
|
scoped_names.add(name)
|
|
elif inflight_scope is None:
|
|
global_names.add(name)
|
|
# Load outside the registry lock; each name has an in-flight event so
|
|
# concurrent readers wait for the same materialization.
|
|
for name in (*sorted(scoped_names), *sorted(global_names)):
|
|
self._resolve(name, active_scope)
|
|
|
|
def register(self, entry: PlatformEntry, *, scope: Optional[str] = None) -> None:
|
|
"""Register a platform adapter entry (last writer wins on name clash)."""
|
|
with self._lock:
|
|
if scope is None and entry.source == "plugin":
|
|
scope = _caller_plugin_scope()
|
|
if scope is None:
|
|
scope = _plugin_scope_from_callable(entry.adapter_factory)
|
|
if scope is None:
|
|
scope = _plugin_scope_from_callable(entry.check_fn)
|
|
# A concrete registration supersedes any pending deferred loader.
|
|
entries, deferred = self._scope_maps(scope, create=True)
|
|
self._consumed_loaders.pop((scope, entry.name), None)
|
|
deferred.pop(entry.name, None)
|
|
prev = entries.get(entry.name)
|
|
if prev is not None:
|
|
logger.info(
|
|
"Platform '%s' re-registered (was %s, now %s)", entry.name, prev.source, entry.source
|
|
)
|
|
entries[entry.name] = entry
|
|
logger.debug("Registered platform adapter: %s (%s)", entry.name, entry.source)
|
|
|
|
def unregister(self, name: str, *, scope: Optional[str] = None) -> bool:
|
|
"""Remove a platform entry. Returns True if it existed."""
|
|
with self._lock:
|
|
inferred_scope = scope if scope is not None else _caller_plugin_scope()
|
|
active_scope = inferred_scope or self.current_scope_key()
|
|
entries, deferred = self._scope_maps(active_scope)
|
|
if inferred_scope is not None or name in entries or name in deferred:
|
|
deferred.pop(name, None)
|
|
removed = entries.pop(name, None) is not None
|
|
self._prune_scope(active_scope)
|
|
return removed
|
|
self._deferred.pop(name, None)
|
|
return self._entries.pop(name, None) is not None
|
|
|
|
def _load_pending(self, scope: str, name: str) -> bool:
|
|
"""True when a lookup of *name* must run/await a deferred loader (lock held)."""
|
|
_entries, deferred = self._scope_maps(scope)
|
|
return (
|
|
name in deferred
|
|
or (name not in self._entries and name in self._deferred)
|
|
or (scope, name) in self._inflight
|
|
or (None, name) in self._inflight
|
|
)
|
|
|
|
def get(self, name: str) -> Optional[PlatformEntry]:
|
|
"""Look up a platform entry by name."""
|
|
scope = self.current_scope_key()
|
|
with self._lock:
|
|
entries, _deferred = self._scope_maps(scope)
|
|
needs_resolve = name not in entries and self._load_pending(scope, name)
|
|
if needs_resolve:
|
|
self._resolve(name, scope)
|
|
with self._lock:
|
|
entries, _deferred = self._scope_maps(scope)
|
|
return entries.get(name) or self._entries.get(name)
|
|
|
|
def all_entries(self) -> list[PlatformEntry]:
|
|
"""Return all registered platform entries."""
|
|
self._resolve_all()
|
|
with self._lock:
|
|
entries = dict(self._entries)
|
|
entries.update(self._scoped_entries.get(self.current_scope_key(), {}))
|
|
return list(entries.values())
|
|
|
|
def plugin_entries(self) -> list[PlatformEntry]:
|
|
"""Return only plugin-registered platform entries."""
|
|
return [e for e in self.all_entries() if e.source == "plugin"]
|
|
|
|
def registered_names(self) -> set[str]:
|
|
"""Concrete and deferred platform names without loading adapters.
|
|
|
|
Same scope semantics as ``is_registered()``: current profile scope AND
|
|
process-global names. Plugin platforms register deferred loaders under
|
|
a profile scope, so the global maps alone would miss every plugin.
|
|
"""
|
|
with self._lock:
|
|
entries, deferred = self._scope_maps(self.current_scope_key())
|
|
return entries.keys() | deferred.keys() | self._entries.keys() | self._deferred.keys()
|
|
|
|
def is_registered(self, name: str) -> bool:
|
|
# A deferred (not-yet-imported) platform still counts as registered so
|
|
# cheap membership checks (toolset resolution, webhook deliver-target
|
|
# checks) never trigger a heavy import.
|
|
with self._lock:
|
|
scope = self.current_scope_key()
|
|
entries, _deferred = self._scope_maps(scope)
|
|
return name in entries or name in self._entries or self._load_pending(scope, name)
|
|
|
|
def create_adapter(self, name: str, config: Any) -> Optional[Any]:
|
|
"""Create an adapter instance for *name*.
|
|
|
|
Returns None when no entry exists, deps are missing and cannot be
|
|
installed, ``validate_config`` fails, or the factory raises.
|
|
"""
|
|
entry = self.get(name)
|
|
if entry is None:
|
|
return None
|
|
|
|
deps_ok = False
|
|
try:
|
|
deps_ok = bool(entry.check_fn())
|
|
except Exception as e:
|
|
logger.warning("Platform '%s' check_fn raised: %s", entry.label, e)
|
|
if not deps_ok and entry.ensure_deps_fn is not None:
|
|
# The ONE place the active installer runs in the adapter path: the
|
|
# platform is enabled+configured and about to connect, so an install
|
|
# is what the user wants. (An installer inside connect() is never
|
|
# reached when check_fn is False -- create_adapter() would have
|
|
# already returned None.)
|
|
logger.info("Platform '%s' dependencies missing — attempting install...", entry.label)
|
|
try:
|
|
deps_ok = bool(entry.ensure_deps_fn())
|
|
except Exception as e:
|
|
logger.warning("Platform '%s' dependency install raised: %s", entry.label, e)
|
|
deps_ok = False
|
|
if not deps_ok:
|
|
hint = f" ({entry.install_hint})" if entry.install_hint else ""
|
|
logger.warning("Platform '%s' requirements not met%s", entry.label, hint)
|
|
return None
|
|
|
|
if entry.validate_config is not None:
|
|
try:
|
|
if not entry.validate_config(config):
|
|
logger.warning("Platform '%s' config validation failed", entry.label)
|
|
return None
|
|
except Exception as e:
|
|
logger.warning("Platform '%s' config validation error: %s", entry.label, e)
|
|
return None
|
|
|
|
try:
|
|
return entry.adapter_factory(config)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to create adapter for platform '%s': %s", entry.label, e, exc_info=True
|
|
)
|
|
return None
|
|
|
|
|
|
# Module-level singleton
|
|
platform_registry = PlatformRegistry()
|