"""Common entry-point discovery and tool-provider lifecycle, without UI imports."""
from __future__ import annotations
import inspect
import logging
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass, field
from importlib.metadata import EntryPoint
from pathlib import Path
from typing import Any, Protocol, runtime_checkable
from .context import ContextStore
from .instructions import Instruction
from .permission import PermissionGuard
from .selector import ToolSelector
from .tool import Tool
logger = logging.getLogger(__name__)
TOOLS_GROUP = "axio.tools"
TOOL_PROVIDERS_GROUP = "axio.tools.providers"
GUARDS_GROUP = "axio.guards"
SELECTOR_GROUP = "axio.selector"
[docs]
class PluginError(RuntimeError):
"""An extension could not be loaded or conflicts with another extension."""
[docs]
@dataclass(frozen=True, slots=True)
class PluginSource:
"""The installed declaration that owns an entry-point name."""
group: str
distribution: str
value: str
[docs]
@dataclass
class PluginCatalog[T]:
"""Successfully loaded extensions, failures, and their installed sources."""
entries: dict[str, T] = field(default_factory=dict)
errors: dict[str, str] = field(default_factory=dict)
sources: dict[str, PluginSource] = field(default_factory=dict)
[docs]
def require(self, name: str) -> T:
if name in self.errors:
raise PluginError(f"Plugin {name!r} failed to load: {self.errors[name]}")
if name not in self.entries:
raise PluginError(f"Unknown plugin {name!r}. Available: {', '.join(sorted(self.entries))}")
return self.entries[name]
[docs]
def load_entry_points[T](
group: str,
adapt: Callable[[str, Any], T],
*,
points: Iterable[EntryPoint] | None = None,
) -> PluginCatalog[T]:
"""Load each name once, retaining failures and reserving the first declaration.
Discovery does not initialize providers. An adapter validates the contract
for its own group. A broken declaration cannot be replaced by a duplicate.
"""
from importlib.metadata import entry_points
catalog: PluginCatalog[T] = PluginCatalog()
for ep in entry_points(group=group) if points is None else points:
if ep.name in catalog.sources:
logger.warning("Duplicate %s entry point %r ignored (first declaration wins)", group, ep.name)
continue
dist = getattr(ep, "dist", None)
catalog.sources[ep.name] = PluginSource(group, getattr(dist, "name", "unknown"), getattr(ep, "value", ""))
try:
catalog.entries[ep.name] = adapt(ep.name, ep.load())
except Exception as error:
catalog.errors[ep.name] = str(error)
logger.warning("Failed to load %s entry point %r: %s", group, ep.name, error)
return catalog
[docs]
def guard_from_target(name: str, target: Any) -> type[PermissionGuard]:
if not isinstance(target, type) or not issubclass(target, PermissionGuard):
raise TypeError("Expected a PermissionGuard subclass")
return target
[docs]
def discover_guards(*, points: Iterable[EntryPoint] | None = None) -> PluginCatalog[type[PermissionGuard]]:
return load_entry_points(GUARDS_GROUP, guard_from_target, points=points)
[docs]
def selector_from_target(name: str, target: Any) -> type[ToolSelector]:
if not isinstance(target, type) or not issubclass(target, ToolSelector):
raise TypeError("Expected a ToolSelector class")
return target
[docs]
def discover_selectors(*, points: Iterable[EntryPoint] | None = None) -> PluginCatalog[type[ToolSelector]]:
return load_entry_points(SELECTOR_GROUP, selector_from_target, points=points)
[docs]
def provider_from_target(name: str, target: Any) -> ToolProvider:
if not callable(target):
raise TypeError("Expected a tool-provider factory")
provider = target()
if not isinstance(provider, ToolProvider):
raise TypeError("Expected a ToolProvider with init, close, all_tools and get_instructions")
return provider
[docs]
@runtime_checkable
class ContextBackend(Protocol):
"""An installed store factory that owns resources shared by its sessions."""
[docs]
async def init(self, path: Path) -> None: ...
[docs]
def create(self, session_id: str | None = None) -> ContextStore: ...
[docs]
async def close(self) -> None: ...
[docs]
def context_backend_from_target(name: str, target: Any) -> ContextBackend:
backend = target()
if not isinstance(backend, ContextBackend):
raise TypeError("Expected a ContextBackend with init, create and close")
return backend
[docs]
def discover_context_backends() -> PluginCatalog[ContextBackend]:
return load_entry_points("axio.context", context_backend_from_target)