updating and refining plugin system refactor

This commit is contained in:
2026-04-28 22:20:54 -07:00
parent 8685fbb723
commit 323c24f4f4
33 changed files with 4287 additions and 3312 deletions
+91 -100
View File
@@ -22,12 +22,24 @@ from urllib.parse import urlparse
from SYS.logger import log, debug
from ProviderCore.base import FileProvider, Provider, SearchProvider, SearchResult
from ProviderCore.base import Provider, SearchResult
_EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH")
def _class_supports_method(
plugin_class: Type[Provider],
method_name: str,
base_method: Any,
) -> bool:
try:
method = getattr(plugin_class, method_name, None)
except Exception:
return False
return callable(method) and method is not base_method
def _repo_root() -> Path:
try:
return Path(__file__).resolve().parents[1]
@@ -102,34 +114,34 @@ def _iter_external_plugin_entries(plugin_dir: Path) -> Iterable[Tuple[str, Path,
return tuple(out)
@dataclass(frozen=True)
class ProviderInfo:
class PluginInfo:
"""Metadata about a single plugin entry."""
canonical_name: str
provider_class: Type[Provider]
plugin_class: Type[Provider]
module: str
alias_names: Tuple[str, ...] = field(default_factory=tuple)
@property
def supports_search(self) -> bool:
return self.provider_class.search is not Provider.search
return _class_supports_method(self.plugin_class, "search", Provider.search)
@property
def supports_upload(self) -> bool:
try:
exposed = bool(getattr(self.provider_class, "EXPOSE_AS_FILE_PROVIDER", True))
exposed = bool(getattr(self.plugin_class, "EXPOSE_AS_FILE_PROVIDER", True))
except Exception:
exposed = True
return exposed and (self.provider_class.upload is not Provider.upload)
return exposed and _class_supports_method(self.plugin_class, "upload", Provider.upload)
class ProviderRegistry:
class PluginRegistry:
"""Handles discovery, registration, and lookup of built-in and external plugins."""
def __init__(self, package_name: str) -> None:
self.package_name = (package_name or "").strip()
self._infos: Dict[str, ProviderInfo] = {}
self._lookup: Dict[str, ProviderInfo] = {}
self._infos: Dict[str, PluginInfo] = {}
self._lookup: Dict[str, PluginInfo] = {}
self._modules: set[str] = set()
self._external_modules: set[str] = set()
self._builtin_package_dirs: Tuple[Path, ...] = ()
@@ -174,7 +186,7 @@ class ProviderRegistry:
return str(value or "").strip().lower()
def _candidate_names(self,
provider_class: Type[Provider],
plugin_class: Type[Provider],
override_name: Optional[str]) -> List[str]:
names: List[str] = []
seen: set[str] = set()
@@ -190,25 +202,25 @@ class ProviderRegistry:
if override_name:
_add(override_name)
else:
_add(getattr(provider_class, "PLUGIN_NAME", None))
_add(getattr(provider_class, "__name__", None))
_add(getattr(plugin_class, "PLUGIN_NAME", None))
_add(getattr(plugin_class, "__name__", None))
for alias in getattr(provider_class, "PLUGIN_ALIASES", ()) or ():
for alias in getattr(plugin_class, "PLUGIN_ALIASES", ()) or ():
_add(alias)
return names
def register(
self,
provider_class: Type[Provider],
plugin_class: Type[Provider],
*,
override_name: Optional[str] = None,
extra_aliases: Optional[Sequence[str]] = None,
module_name: Optional[str] = None,
replace: bool = False,
) -> ProviderInfo:
) -> PluginInfo:
"""Register a plugin class with canonical and alias names."""
candidates = self._candidate_names(provider_class, override_name)
candidates = self._candidate_names(plugin_class, override_name)
if not candidates:
raise ValueError("plugin name candidates are required")
@@ -233,10 +245,10 @@ class ProviderRegistry:
alias_seen.add(normalized)
alias_names.append(normalized)
info = ProviderInfo(
info = PluginInfo(
canonical_name=canonical,
provider_class=provider_class,
module=module_name or getattr(provider_class, "__module__", "") or "",
plugin_class=plugin_class,
module=module_name or getattr(plugin_class, "__module__", "") or "",
alias_names=tuple(alias_names),
)
@@ -261,7 +273,7 @@ class ProviderRegistry:
continue
if not issubclass(candidate, Provider):
continue
if candidate in {Provider, SearchProvider, FileProvider}:
if candidate is Provider:
continue
if getattr(candidate, "__module__", "") != module_name:
continue
@@ -311,7 +323,7 @@ class ProviderRegistry:
log(f"[plugin] Failed to load external plugin {module_path}: {exc}", file=sys.stderr)
def discover(self) -> None:
"""Import and register providers from the package."""
"""Import and register plugins from the package."""
if self._discovered or not self.package_name:
return
@@ -376,7 +388,7 @@ class ProviderRegistry:
self._sync_subclasses()
return
def get(self, name: str) -> Optional[ProviderInfo]:
def get(self, name: str) -> Optional[PluginInfo]:
if not name:
return None
@@ -398,7 +410,7 @@ class ProviderRegistry:
self.discover()
return self._lookup.get(normalized)
def iter_providers(self) -> Iterable[ProviderInfo]:
def iter_plugins(self) -> Iterable[PluginInfo]:
self.discover()
return tuple(self._infos.values())
@@ -406,12 +418,9 @@ class ProviderRegistry:
return self.get(name) is not None
def _sync_subclasses(self) -> None:
"""Walk all Provider subclasses in memory and register them."""
"""Walk all plugin subclasses in memory and register them."""
def _walk(cls: Type[Provider]) -> None:
for sub in cls.__subclasses__():
if sub in {SearchProvider, FileProvider}:
_walk(sub)
continue
try:
self.register(sub)
except Exception:
@@ -419,16 +428,14 @@ class ProviderRegistry:
_walk(sub)
_walk(Provider)
REGISTRY = ProviderRegistry("plugins")
REGISTRY = PluginRegistry("plugins")
PLUGIN_REGISTRY = REGISTRY
PluginInfo = ProviderInfo
PluginRegistry = ProviderRegistry
@lru_cache(maxsize=512)
def _provider_url_patterns(provider_class: Type[Provider]) -> Sequence[str]:
def _plugin_url_patterns(plugin_class: Type[Provider]) -> Sequence[str]:
try:
return list(provider_class.url_patterns())
return list(plugin_class.url_patterns())
except Exception:
return []
@@ -440,7 +447,7 @@ def register_plugin(
aliases: Optional[Sequence[str]] = None,
module_name: Optional[str] = None,
replace: bool = False,
) -> ProviderInfo:
) -> PluginInfo:
return REGISTRY.register(
plugin_class,
override_name=name,
@@ -454,7 +461,7 @@ def get_plugin_class(name: str) -> Optional[Type[Provider]]:
info = REGISTRY.get(name)
if info is None:
return None
return info.provider_class
return info.plugin_class
def selection_auto_stage_for_table(
@@ -481,7 +488,7 @@ def is_known_plugin_name(name: str) -> bool:
def _supports_search(provider: Provider) -> bool:
return provider.__class__.search is not Provider.search
return _class_supports_method(provider.__class__, "search", Provider.search)
def _supports_upload(provider: Provider) -> bool:
@@ -489,7 +496,25 @@ def _supports_upload(provider: Provider) -> bool:
exposed = bool(getattr(provider.__class__, "EXPOSE_AS_FILE_PROVIDER", True))
except Exception:
exposed = True
return exposed and (provider.__class__.upload is not Provider.upload)
return exposed and _class_supports_method(provider.__class__, "upload", Provider.upload)
def _supports_capability(provider: Provider, capability: str) -> bool:
capability_key = str(capability or "").strip().lower()
if capability_key == "search":
return _supports_search(provider)
if capability_key in {"upload", "file", "file-provider"}:
return _supports_upload(provider)
return False
def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
capability_key = str(capability or "").strip().lower()
if capability_key == "search":
return bool(info.supports_search)
if capability_key in {"upload", "file", "file-provider"}:
return bool(info.supports_upload)
return False
def _normalize_choice_entry(entry: Any) -> Optional[Dict[str, Any]]:
@@ -555,7 +580,7 @@ def get_plugin(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[P
return None
try:
plugin = info.provider_class(config)
plugin = info.plugin_class(config)
if not plugin.validate():
debug(f"[plugin] Plugin '{name}' is not available")
return None
@@ -567,78 +592,50 @@ def get_plugin(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[P
def list_plugins(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
availability: Dict[str, bool] = {}
for info in REGISTRY.iter_providers():
for info in REGISTRY.iter_plugins():
try:
plugin = info.provider_class(config)
plugin = info.plugin_class(config)
availability[info.canonical_name] = plugin.validate()
except Exception:
availability[info.canonical_name] = False
return availability
def get_search_plugin(name: str,
config: Optional[Dict[str, Any]] = None) -> Optional[SearchProvider]:
def get_plugin_with_capability(
name: str,
capability: str,
config: Optional[Dict[str, Any]] = None,
) -> Optional[Provider]:
plugin = get_plugin(name, config)
if plugin is None:
return None
if not _supports_search(plugin):
debug(f"[plugin] Plugin '{name}' does not support search")
if not _supports_capability(plugin, capability):
debug(f"[plugin] Plugin '{name}' does not support capability '{capability}'")
return None
return plugin # type: ignore[return-value]
return plugin
def list_search_plugins(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
def list_plugins_with_capability(
capability: str,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, bool]:
availability: Dict[str, bool] = {}
for info in REGISTRY.iter_providers():
for info in REGISTRY.iter_plugins():
try:
plugin = info.provider_class(config)
plugin = info.plugin_class(config)
availability[info.canonical_name] = bool(
plugin.validate() and info.supports_search
plugin.validate() and _supports_capability(plugin, capability)
)
except Exception:
availability[info.canonical_name] = False
return availability
def list_search_plugin_names() -> List[str]:
"""Return registered search-provider names without instantiating plugins."""
def list_plugin_names_with_capability(capability: str) -> List[str]:
return sorted(
info.canonical_name
for info in REGISTRY.iter_providers()
if info.supports_search
)
def get_upload_plugin(name: str,
config: Optional[Dict[str, Any]] = None) -> Optional[FileProvider]:
plugin = get_plugin(name, config)
if plugin is None:
return None
if not _supports_upload(plugin):
debug(f"[plugin] Plugin '{name}' does not support upload")
return None
return plugin # type: ignore[return-value]
def list_upload_plugins(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
availability: Dict[str, bool] = {}
for info in REGISTRY.iter_providers():
try:
plugin = info.provider_class(config)
availability[info.canonical_name] = bool(
plugin.validate() and info.supports_upload
)
except Exception:
availability[info.canonical_name] = False
return availability
def list_upload_plugin_names() -> List[str]:
"""Return registered upload-provider names without instantiating plugins."""
return sorted(
info.canonical_name
for info in REGISTRY.iter_providers()
if info.supports_upload
for info in REGISTRY.iter_plugins()
if _info_supports_capability(info, capability)
)
@@ -677,8 +674,8 @@ def match_plugin_name_for_url(url: str) -> Optional[str]:
return "openlibrary" if REGISTRY.has_name("openlibrary") else None
return "internetarchive" if REGISTRY.has_name("internetarchive") else None
for info in REGISTRY.iter_providers():
domains = _provider_url_patterns(info.provider_class)
for info in REGISTRY.iter_plugins():
domains = _plugin_url_patterns(info.plugin_class)
if not domains:
continue
for domain in domains:
@@ -721,12 +718,10 @@ def plugin_inline_query_choices(
mapping: Dict[str, List[Dict[str, Any]]] = {}
info = REGISTRY.get(pname)
if info is not None:
mapping = _collect_inline_choice_mapping(info.provider_class)
mapping = _collect_inline_choice_mapping(info.plugin_class)
if not mapping:
plugin = get_search_plugin(pname, config)
if plugin is None:
plugin = get_plugin(pname, config)
plugin = get_plugin(pname, config)
if plugin is None:
return []
mapping = _collect_inline_choice_mapping(plugin)
@@ -770,9 +765,9 @@ def get_plugin_for_url(url: str,
def list_selection_url_prefixes() -> List[str]:
prefixes: List[str] = []
seen: set[str] = set()
for info in REGISTRY.iter_providers():
for info in REGISTRY.iter_plugins():
try:
values = info.provider_class.selection_url_prefixes()
values = info.plugin_class.selection_url_prefixes()
except Exception:
values = ()
for value in values or ():
@@ -842,21 +837,17 @@ def resolve_inline_filters(
__all__ = [
"ProviderInfo",
"PluginInfo",
"Provider",
"SearchProvider",
"FileProvider",
"SearchResult",
"PluginRegistry",
"PLUGIN_REGISTRY",
"register_plugin",
"get_plugin",
"list_plugins",
"get_search_plugin",
"list_search_plugins",
"get_upload_plugin",
"list_upload_plugins",
"get_plugin_with_capability",
"list_plugins_with_capability",
"list_plugin_names_with_capability",
"match_plugin_name_for_url",
"get_plugin_for_url",
"list_selection_url_prefixes",