72 lines
2.3 KiB
Python
72 lines
2.3 KiB
Python
|
|
"""Auto-discovery for built-in channel modules and external plugins."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import importlib
|
||
|
|
import pkgutil
|
||
|
|
from typing import TYPE_CHECKING
|
||
|
|
|
||
|
|
from loguru import logger
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from nanobot.channels.base import BaseChannel
|
||
|
|
|
||
|
|
_INTERNAL = frozenset({"base", "manager", "registry"})
|
||
|
|
|
||
|
|
|
||
|
|
def discover_channel_names() -> list[str]:
|
||
|
|
"""Return all built-in channel module names by scanning the package (zero imports)."""
|
||
|
|
import nanobot.channels as pkg
|
||
|
|
|
||
|
|
return [
|
||
|
|
name
|
||
|
|
for _, name, ispkg in pkgutil.iter_modules(pkg.__path__)
|
||
|
|
if name not in _INTERNAL and not ispkg
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def load_channel_class(module_name: str) -> type[BaseChannel]:
|
||
|
|
"""Import *module_name* and return the first BaseChannel subclass found."""
|
||
|
|
from nanobot.channels.base import BaseChannel as _Base
|
||
|
|
|
||
|
|
mod = importlib.import_module(f"nanobot.channels.{module_name}")
|
||
|
|
for attr in dir(mod):
|
||
|
|
obj = getattr(mod, attr)
|
||
|
|
if isinstance(obj, type) and issubclass(obj, _Base) and obj is not _Base:
|
||
|
|
return obj
|
||
|
|
raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}")
|
||
|
|
|
||
|
|
|
||
|
|
def discover_plugins() -> dict[str, type[BaseChannel]]:
|
||
|
|
"""Discover external channel plugins registered via entry_points."""
|
||
|
|
from importlib.metadata import entry_points
|
||
|
|
|
||
|
|
plugins: dict[str, type[BaseChannel]] = {}
|
||
|
|
for ep in entry_points(group="nanobot.channels"):
|
||
|
|
try:
|
||
|
|
cls = ep.load()
|
||
|
|
plugins[ep.name] = cls
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning("Failed to load channel plugin '{}': {}", ep.name, e)
|
||
|
|
return plugins
|
||
|
|
|
||
|
|
|
||
|
|
def discover_all() -> dict[str, type[BaseChannel]]:
|
||
|
|
"""Return all channels: built-in (pkgutil) merged with external (entry_points).
|
||
|
|
|
||
|
|
Built-in channels take priority — an external plugin cannot shadow a built-in name.
|
||
|
|
"""
|
||
|
|
builtin: dict[str, type[BaseChannel]] = {}
|
||
|
|
for modname in discover_channel_names():
|
||
|
|
try:
|
||
|
|
builtin[modname] = load_channel_class(modname)
|
||
|
|
except ImportError as e:
|
||
|
|
logger.debug("Skipping built-in channel '{}': {}", modname, e)
|
||
|
|
|
||
|
|
external = discover_plugins()
|
||
|
|
shadowed = set(external) & set(builtin)
|
||
|
|
if shadowed:
|
||
|
|
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
|
||
|
|
|
||
|
|
return {**external, **builtin}
|