Files
Medios-Macina/ProviderCore/registry.py
T

1003 lines
33 KiB
Python
Raw Normal View History

"""Plugin registry.
2025-12-11 19:04:02 -08:00
2026-04-26 16:49:23 -07:00
Bundled plugins live in the ``plugins`` package. Additional drop-in plugins can
be discovered from external plugin directories configured via the environment or
current working directory.
2025-12-11 19:04:02 -08:00
"""
from __future__ import annotations
2026-02-11 18:16:07 -08:00
from functools import lru_cache
import hashlib
2026-01-05 07:51:19 -08:00
import importlib
import importlib.util
import os
2026-01-05 07:51:19 -08:00
import pkgutil
2025-12-11 19:04:02 -08:00
import sys
2026-01-05 07:51:19 -08:00
from dataclasses import dataclass, field
from pathlib import Path
2026-01-05 07:51:19 -08:00
from types import ModuleType
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type
2025-12-22 02:11:53 -08:00
from urllib.parse import urlparse
2025-12-11 19:04:02 -08:00
2026-01-11 04:54:27 -08:00
from SYS.logger import log, debug
2025-12-11 19:04:02 -08:00
from ProviderCore.base import Provider, SearchResult
2026-02-11 18:16:07 -08:00
_EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH")
2026-02-11 18:16:07 -08:00
# Plugin instance cache keyed by (name, config_fingerprint)
_plugin_instance_cache: Dict[Tuple[str, str], Optional[Provider]] = {}
_plugin_cache_lock = __import__("threading").Lock()
def _config_fingerprint(config: Optional[Dict[str, Any]]) -> str:
"""Create a stable fingerprint of config for caching purposes."""
if config is None:
return "none"
try:
import json
normalized = json.dumps(config, sort_keys=True, default=str)
return hashlib.md5(normalized.encode()).hexdigest()[:16]
except Exception:
return "unknown"
2026-02-11 18:16:07 -08:00
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]
except Exception:
return Path.cwd()
2026-02-11 18:16:07 -08:00
2025-12-11 19:04:02 -08:00
def _iter_external_plugin_dirs() -> Tuple[Path, ...]:
seen: set[str] = set()
dirs: List[Path] = []
candidates: List[Path] = [_repo_root() / "plugins"]
try:
cwd_plugins = Path.cwd() / "plugins"
if cwd_plugins not in candidates:
candidates.append(cwd_plugins)
except Exception:
pass
for env_name in _EXTERNAL_PLUGIN_ENV_VARS:
raw_value = str(os.environ.get(env_name, "") or "").strip()
if not raw_value:
continue
for chunk in raw_value.split(os.pathsep):
text = str(chunk or "").strip().strip('"')
if not text:
continue
candidates.append(Path(text).expanduser())
for candidate in candidates:
try:
resolved = candidate.resolve()
except Exception:
resolved = candidate
key = str(resolved).lower()
if key in seen:
continue
seen.add(key)
try:
if resolved.exists() and resolved.is_dir():
dirs.append(resolved)
except Exception:
continue
return tuple(dirs)
def _iter_external_plugin_entries(plugin_dir: Path) -> Iterable[Tuple[str, Path, bool]]:
try:
children = sorted(plugin_dir.iterdir(), key=lambda entry: entry.name.lower())
except Exception:
return ()
out: List[Tuple[str, Path, bool]] = []
for child in children:
name = str(child.name or "").strip()
if not name or name.startswith("."):
continue
if child.is_file() and child.suffix.lower() == ".py" and child.stem != "__init__":
fingerprint = hashlib.sha1(str(child).encode("utf-8", errors="ignore")).hexdigest()[:10]
out.append((f"_medeia_plugin_{child.stem}_{fingerprint}", child, False))
continue
if child.is_dir():
init_py = child / "__init__.py"
if not init_py.exists() or not init_py.is_file():
continue
fingerprint = hashlib.sha1(str(child).encode("utf-8", errors="ignore")).hexdigest()[:10]
out.append((f"_medeia_plugin_pkg_{child.name}_{fingerprint}", init_py, True))
return tuple(out)
2026-01-05 07:51:19 -08:00
@dataclass(frozen=True)
class PluginInfo:
"""Metadata about a single plugin entry."""
2026-01-05 07:51:19 -08:00
canonical_name: str
plugin_class: Type[Provider]
2026-01-05 07:51:19 -08:00
module: str
alias_names: Tuple[str, ...] = field(default_factory=tuple)
@property
def supports_search(self) -> bool:
return _class_supports_method(self.plugin_class, "search", Provider.search)
2026-01-05 07:51:19 -08:00
@property
def supports_upload(self) -> bool:
2026-02-11 18:16:07 -08:00
try:
exposed = bool(getattr(self.plugin_class, "EXPOSE_AS_FILE_PROVIDER", True))
2026-02-11 18:16:07 -08:00
except Exception:
exposed = True
return exposed and _class_supports_method(self.plugin_class, "upload", Provider.upload)
2026-01-05 07:51:19 -08:00
2026-05-03 21:20:05 -07:00
@property
def is_multi_instance(self) -> bool:
"""True if the plugin declares MULTI_INSTANCE = True."""
return bool(getattr(self.plugin_class, "MULTI_INSTANCE", False))
@property
def supported_cmdlets(self) -> frozenset:
"""Frozenset of cmdlet names this plugin declares support for."""
raw = getattr(self.plugin_class, "SUPPORTED_CMDLETS", frozenset())
try:
return frozenset(str(c) for c in raw)
except Exception:
return frozenset()
2026-01-05 07:51:19 -08:00
class PluginRegistry:
"""Handles discovery, registration, and lookup of built-in and external plugins."""
2026-01-05 07:51:19 -08:00
def __init__(self, package_name: str) -> None:
self.package_name = (package_name or "").strip()
self._infos: Dict[str, PluginInfo] = {}
self._lookup: Dict[str, PluginInfo] = {}
2026-01-05 07:51:19 -08:00
self._modules: set[str] = set()
self._external_modules: set[str] = set()
2026-04-26 16:49:23 -07:00
self._builtin_package_dirs: Tuple[Path, ...] = ()
2026-01-05 07:51:19 -08:00
self._discovered = False
self._external_dirs_scanned = False
2026-01-05 07:51:19 -08:00
def _ensure_builtin_package_dirs(self) -> None:
if self._builtin_package_dirs or not self.package_name:
return
try:
package = importlib.import_module(self.package_name)
except Exception:
return
package_path = getattr(package, "__path__", None)
if not package_path:
return
builtin_dirs: List[Path] = []
for entry in package_path:
try:
builtin_dirs.append(Path(str(entry)).resolve())
except Exception:
builtin_dirs.append(Path(str(entry)))
self._builtin_package_dirs = tuple(builtin_dirs)
2026-04-26 16:49:23 -07:00
def _is_builtin_package_dir(self, candidate: Path) -> bool:
self._ensure_builtin_package_dirs()
2026-04-26 16:49:23 -07:00
try:
resolved = candidate.resolve()
except Exception:
resolved = candidate
for package_dir in self._builtin_package_dirs:
try:
if resolved == package_dir:
return True
except Exception:
continue
return False
2026-01-05 07:51:19 -08:00
def _normalize(self, value: Any) -> str:
return str(value or "").strip().lower()
def _candidate_names(self,
plugin_class: Type[Provider],
2026-01-05 07:51:19 -08:00
override_name: Optional[str]) -> List[str]:
names: List[str] = []
seen: set[str] = set()
def _add(value: Any) -> None:
text = str(value or "").strip()
normalized = text.lower()
if not text or normalized in seen:
return
seen.add(normalized)
names.append(text)
if override_name:
_add(override_name)
else:
_add(getattr(plugin_class, "PLUGIN_NAME", None))
_add(getattr(plugin_class, "__name__", None))
2026-01-05 07:51:19 -08:00
for alias in getattr(plugin_class, "PLUGIN_ALIASES", ()) or ():
2026-01-05 07:51:19 -08:00
_add(alias)
return names
def register(
self,
plugin_class: Type[Provider],
2026-01-05 07:51:19 -08:00
*,
override_name: Optional[str] = None,
extra_aliases: Optional[Sequence[str]] = None,
module_name: Optional[str] = None,
replace: bool = False,
) -> PluginInfo:
"""Register a plugin class with canonical and alias names."""
candidates = self._candidate_names(plugin_class, override_name)
2026-01-05 07:51:19 -08:00
if not candidates:
raise ValueError("plugin name candidates are required")
2026-01-05 07:51:19 -08:00
canonical = self._normalize(candidates[0])
if not canonical:
raise ValueError("plugin name must not be empty")
2026-01-05 07:51:19 -08:00
alias_names: List[str] = []
alias_seen: set[str] = set()
for candidate in candidates[1:]:
normalized = self._normalize(candidate)
if not normalized or normalized == canonical or normalized in alias_seen:
continue
alias_seen.add(normalized)
alias_names.append(normalized)
for alias in extra_aliases or ():
normalized = self._normalize(alias)
if not normalized or normalized == canonical or normalized in alias_seen:
continue
alias_seen.add(normalized)
alias_names.append(normalized)
info = PluginInfo(
2026-01-05 07:51:19 -08:00
canonical_name=canonical,
plugin_class=plugin_class,
module=module_name or getattr(plugin_class, "__module__", "") or "",
2026-01-05 07:51:19 -08:00
alias_names=tuple(alias_names),
)
existing = self._infos.get(canonical)
if existing is not None and not replace:
return existing
self._infos[canonical] = info
for lookup in (canonical,) + tuple(alias_names):
self._lookup[lookup] = info
return info
def _register_module(self, module: ModuleType) -> None:
module_name = getattr(module, "__name__", "")
if not module_name or module_name in self._modules:
return
self._modules.add(module_name)
2026-02-11 18:16:07 -08:00
# Iterate module dict directly (faster than dir()+getattr()).
for candidate in vars(module).values():
2026-01-05 07:51:19 -08:00
if not isinstance(candidate, type):
continue
if not issubclass(candidate, Provider):
continue
if candidate is Provider:
2026-01-05 07:51:19 -08:00
continue
if getattr(candidate, "__module__", "") != module_name:
continue
try:
self.register(candidate, module_name=module_name)
except Exception as exc:
log(f"[plugin] Failed to register {module_name}.{candidate.__name__}: {exc}", file=sys.stderr)
def _discover_external_plugins(self) -> None:
if self._external_dirs_scanned:
return
self._external_dirs_scanned = True
self._ensure_builtin_package_dirs()
for plugin_dir in _iter_external_plugin_dirs():
2026-04-26 16:49:23 -07:00
if self._is_builtin_package_dir(plugin_dir):
continue
try:
plugin_dir_str = str(plugin_dir)
if plugin_dir_str and plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
except Exception:
pass
for module_name, module_path, is_package in _iter_external_plugin_entries(plugin_dir):
if module_name in self._external_modules:
continue
try:
if is_package:
spec = importlib.util.spec_from_file_location(
module_name,
str(module_path),
submodule_search_locations=[str(module_path.parent)],
)
else:
spec = importlib.util.spec_from_file_location(module_name, str(module_path))
if spec is None or spec.loader is None:
raise ImportError("missing module spec loader")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
self._external_modules.add(module_name)
self._register_module(module)
except Exception as exc:
log(f"[plugin] Failed to load external plugin {module_path}: {exc}", file=sys.stderr)
2026-01-05 07:51:19 -08:00
def discover(self) -> None:
"""Import and register plugins from the package."""
2026-01-05 07:51:19 -08:00
if self._discovered or not self.package_name:
return
self._discovered = True
try:
package = importlib.import_module(self.package_name)
except Exception as exc:
log(f"[plugin] Failed to import package {self.package_name}: {exc}", file=sys.stderr)
2026-01-05 07:51:19 -08:00
return
self._register_module(package)
self._ensure_builtin_package_dirs()
2026-01-05 07:51:19 -08:00
package_path = getattr(package, "__path__", None)
if not package_path:
self._discover_external_plugins()
2026-01-05 07:51:19 -08:00
return
for finder, module_name, _ in pkgutil.iter_modules(package_path):
if module_name.startswith("_"):
continue
module_path = f"{self.package_name}.{module_name}"
try:
module = importlib.import_module(module_path)
except Exception as exc:
log(f"[plugin] Failed to load {module_path}: {exc}", file=sys.stderr)
2026-01-05 07:51:19 -08:00
continue
self._register_module(module)
2026-02-11 18:16:07 -08:00
# Pick up any Provider subclasses loaded via other mechanisms.
self._sync_subclasses()
self._discover_external_plugins()
2026-02-11 18:16:07 -08:00
def _try_import_for_name(self, normalized_name: str) -> None:
"""Best-effort import for a single plugin module.
2026-02-11 18:16:07 -08:00
This avoids importing every provider module when the caller only needs
one plugin (common for CLI usage).
2026-02-11 18:16:07 -08:00
"""
name = str(normalized_name or "").strip().lower()
if not name or not self.package_name:
return
candidates: List[str] = [name]
if "-" in name:
candidates.append(name.replace("-", "_"))
if "." in name:
candidates.append(name.split(".", 1)[0])
for mod_name in candidates:
if not mod_name:
continue
module_path = f"{self.package_name}.{mod_name}"
if module_path in self._modules:
continue
try:
module = importlib.import_module(module_path)
except Exception:
continue
self._register_module(module)
# Pick up subclasses in case the module registers indirectly.
self._sync_subclasses()
return
def get(self, name: str) -> Optional[PluginInfo]:
2026-01-05 07:51:19 -08:00
if not name:
return None
2026-02-11 18:16:07 -08:00
normalized = self._normalize(name)
info = self._lookup.get(normalized)
if info is not None:
return info
# If we haven't done a full discovery yet, try importing just the
# module that matches the requested name.
if not self._discovered:
self._try_import_for_name(normalized)
self._discover_external_plugins()
2026-02-11 18:16:07 -08:00
info = self._lookup.get(normalized)
if info is not None:
return info
# Fall back to full package scan.
self.discover()
return self._lookup.get(normalized)
2026-01-05 07:51:19 -08:00
def iter_plugins(self) -> Iterable[PluginInfo]:
2026-01-05 07:51:19 -08:00
self.discover()
return tuple(self._infos.values())
def has_name(self, name: str) -> bool:
return self.get(name) is not None
2026-05-03 21:20:05 -07:00
def get_plugins_for_cmdlet(self, cmdlet_name: str) -> List[PluginInfo]:
"""Return all plugins that declare support for the given cmdlet name."""
self.discover()
target = str(cmdlet_name or "").strip().lower()
return [
info for info in self._infos.values()
if target in info.supported_cmdlets
]
def list_storage_plugin_instances(
self,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, List[str]]:
"""Return {plugin_name: [instance_name, ...]} for all MULTI_INSTANCE storage plugins.
Instance names come from the plugin's resolved config (plugin/provider/store sections).
Plugins with no configured instances are omitted.
"""
self.discover()
result: Dict[str, List[str]] = {}
for info in self._infos.values():
if not info.is_multi_instance:
continue
if not info.supported_cmdlets.intersection(
{"add-file", "get-file", "get-tag", "add-tag"}
):
continue
try:
instance = info.plugin_class(config or {})
instances = instance.configured_instances()
if instances:
result[info.canonical_name] = instances
except Exception:
pass
return result
2026-01-11 14:46:41 -08:00
def _sync_subclasses(self) -> None:
"""Walk all plugin subclasses in memory and register them."""
2026-01-11 14:46:41 -08:00
def _walk(cls: Type[Provider]) -> None:
for sub in cls.__subclasses__():
try:
self.register(sub)
except Exception:
pass
_walk(sub)
_walk(Provider)
2026-01-05 07:51:19 -08:00
REGISTRY = PluginRegistry("plugins")
PLUGIN_REGISTRY = REGISTRY
2026-02-11 18:16:07 -08:00
@lru_cache(maxsize=512)
def _plugin_url_patterns(plugin_class: Type[Provider]) -> Sequence[str]:
2026-02-11 18:16:07 -08:00
try:
return list(plugin_class.url_patterns())
2026-02-11 18:16:07 -08:00
except Exception:
return []
2026-01-05 07:51:19 -08:00
def register_plugin(
plugin_class: Type[Provider],
2026-01-05 07:51:19 -08:00
*,
name: Optional[str] = None,
aliases: Optional[Sequence[str]] = None,
module_name: Optional[str] = None,
replace: bool = False,
) -> PluginInfo:
2026-01-05 07:51:19 -08:00
return REGISTRY.register(
plugin_class,
2026-01-05 07:51:19 -08:00
override_name=name,
extra_aliases=aliases,
module_name=module_name,
replace=replace,
)
2025-12-11 19:04:02 -08:00
def get_plugin_class(name: str) -> Optional[Type[Provider]]:
2026-01-05 07:51:19 -08:00
info = REGISTRY.get(name)
if info is None:
return None
return info.plugin_class
2026-01-03 03:37:48 -08:00
def selection_auto_stage_for_table(
table_type: str,
stage_args: Optional[Sequence[str]] = None,
) -> Optional[list[str]]:
t = str(table_type or "").strip().lower()
if not t:
return None
plugin_key = t.split(".", 1)[0] if "." in t else t
plugin_class = get_plugin_class(plugin_key) or get_plugin_class(t)
if plugin_class is None:
2026-01-03 03:37:48 -08:00
return None
try:
return plugin_class.selection_auto_stage(t, stage_args)
2026-01-03 03:37:48 -08:00
except Exception:
return None
def is_known_plugin_name(name: str) -> bool:
2026-01-05 07:51:19 -08:00
return REGISTRY.has_name(name)
2025-12-21 05:10:09 -08:00
2025-12-19 02:29:42 -08:00
def _supports_search(provider: Provider) -> bool:
return _class_supports_method(provider.__class__, "search", Provider.search)
2025-12-11 19:04:02 -08:00
2025-12-19 02:29:42 -08:00
def _supports_upload(provider: Provider) -> bool:
2026-01-01 20:37:27 -08:00
try:
2026-02-11 18:16:07 -08:00
exposed = bool(getattr(provider.__class__, "EXPOSE_AS_FILE_PROVIDER", True))
2026-01-01 20:37:27 -08:00
except Exception:
2026-02-11 18:16:07 -08:00
exposed = True
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)
2026-04-30 18:56:22 -07:00
if capability_key in {"pipe-item-context", "pipe-context"}:
return _class_supports_method(
provider.__class__,
"resolve_pipe_item_context",
Provider.resolve_pipe_item_context,
)
if capability_key in {"playlist-store", "playback-store"}:
return _class_supports_method(
provider.__class__,
"infer_playlist_store",
Provider.infer_playlist_store,
)
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)
2026-04-30 18:56:22 -07:00
if capability_key in {"pipe-item-context", "pipe-context"}:
return _class_supports_method(
info.plugin_class,
"resolve_pipe_item_context",
Provider.resolve_pipe_item_context,
)
if capability_key in {"playlist-store", "playback-store"}:
return _class_supports_method(
info.plugin_class,
"infer_playlist_store",
Provider.infer_playlist_store,
)
return False
2026-02-11 18:16:07 -08:00
def _normalize_choice_entry(entry: Any) -> Optional[Dict[str, Any]]:
if entry is None:
return None
if isinstance(entry, dict):
value = entry.get("value")
text = entry.get("text") or entry.get("label") or value
aliases = entry.get("alias") or entry.get("aliases") or []
value_str = str(value) if value is not None else (str(text) if text is not None else None)
text_str = str(text) if text is not None else value_str
if not value_str or not text_str:
return None
alias_list = [str(a) for a in aliases if a is not None]
return {"value": value_str, "text": text_str, "aliases": alias_list}
return {"value": str(entry), "text": str(entry), "aliases": []}
def _collect_inline_choice_mapping(provider: Provider) -> Dict[str, List[Dict[str, Any]]]:
mapping: Dict[str, List[Dict[str, Any]]] = {}
base = getattr(provider, "QUERY_ARG_CHOICES", None)
if not isinstance(base, dict):
base = getattr(provider, "INLINE_QUERY_FIELD_CHOICES", None)
def _merge_from(obj: Any) -> None:
if not isinstance(obj, dict):
return
for key, value in obj.items():
normalized: List[Dict[str, Any]] = []
seq = value
try:
if callable(seq):
seq = seq()
except Exception:
seq = value
if isinstance(seq, dict):
seq = seq.get("choices") or seq.get("values") or seq
if isinstance(seq, (list, tuple, set)):
for entry in seq:
n = _normalize_choice_entry(entry)
if n:
normalized.append(n)
if normalized:
mapping[str(key).strip().lower()] = normalized
_merge_from(base)
try:
fn = getattr(provider, "inline_query_field_choices", None)
if callable(fn):
_merge_from(fn())
except Exception:
pass
return mapping
2026-01-01 20:37:27 -08:00
def get_plugin(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]:
2026-01-05 07:51:19 -08:00
info = REGISTRY.get(name)
if info is None:
debug(f"[plugin] Unknown plugin: {name}")
2025-12-11 19:04:02 -08:00
return None
# Check cache first
cache_key = (str(name).strip().lower(), _config_fingerprint(config))
with _plugin_cache_lock:
if cache_key in _plugin_instance_cache:
return _plugin_instance_cache[cache_key]
2025-12-11 19:04:02 -08:00
try:
plugin = info.plugin_class(config)
if not plugin.validate():
debug(f"[plugin] Plugin '{name}' is not available")
with _plugin_cache_lock:
_plugin_instance_cache[cache_key] = None
2025-12-11 19:04:02 -08:00
return None
with _plugin_cache_lock:
_plugin_instance_cache[cache_key] = plugin
return plugin
2025-12-11 19:04:02 -08:00
except Exception as exc:
debug(f"[plugin] Error initializing '{name}': {exc}")
with _plugin_cache_lock:
_plugin_instance_cache[cache_key] = None
2025-12-11 19:04:02 -08:00
return None
def list_plugins(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
2026-01-05 07:51:19 -08:00
availability: Dict[str, bool] = {}
for info in REGISTRY.iter_plugins():
2025-12-11 19:04:02 -08:00
try:
plugin = info.plugin_class(config)
availability[info.canonical_name] = plugin.validate()
2025-12-11 19:04:02 -08:00
except Exception:
2026-01-05 07:51:19 -08:00
availability[info.canonical_name] = False
2025-12-11 19:04:02 -08:00
return availability
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:
2025-12-19 02:29:42 -08:00
return None
if not _supports_capability(plugin, capability):
debug(f"[plugin] Plugin '{name}' does not support capability '{capability}'")
2025-12-19 02:29:42 -08:00
return None
return plugin
2025-12-19 02:29:42 -08:00
def list_plugins_with_capability(
capability: str,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, bool]:
2026-01-05 07:51:19 -08:00
availability: Dict[str, bool] = {}
for info in REGISTRY.iter_plugins():
2025-12-19 02:29:42 -08:00
try:
plugin = info.plugin_class(config)
2026-01-05 07:51:19 -08:00
availability[info.canonical_name] = bool(
plugin.validate() and _supports_capability(plugin, capability)
)
2025-12-19 02:29:42 -08:00
except Exception:
2026-01-05 07:51:19 -08:00
availability[info.canonical_name] = False
2025-12-19 02:29:42 -08:00
return availability
2025-12-11 19:04:02 -08:00
def list_plugin_names_with_capability(capability: str) -> List[str]:
2026-04-26 15:08:35 -07:00
return sorted(
info.canonical_name
for info in REGISTRY.iter_plugins()
if _info_supports_capability(info, capability)
2026-04-26 15:08:35 -07:00
)
2026-05-03 21:20:05 -07:00
def list_configured_plugin_names_with_capability(
capability: str,
config: Optional[Dict[str, Any]] = None,
) -> List[str]:
"""Return plugin names that support `capability` AND have configuration present.
For MULTI_INSTANCE plugins (e.g. hydrusnetwork, ftp) the plugin must have at
least one configured instance. For single-instance plugins the plugin's section
must exist under config["plugin"] or config["provider"].
"""
cfg = config or {}
plugin_section: Dict[str, Any] = cfg.get("plugin") or {} # type: ignore[assignment]
provider_section: Dict[str, Any] = cfg.get("provider") or {} # type: ignore[assignment]
result: List[str] = []
for info in REGISTRY.iter_plugins():
if not _info_supports_capability(info, capability):
continue
name = info.canonical_name
if info.is_multi_instance:
try:
instances = info.plugin_class(cfg).configured_instances()
if instances:
result.append(name)
except Exception:
pass
else:
pname = name.lower()
if isinstance(plugin_section.get(pname), dict) or isinstance(provider_section.get(pname), dict):
result.append(name)
return sorted(result)
def match_plugin_name_for_url(url: str) -> Optional[str]:
2026-01-01 20:37:27 -08:00
raw_url = str(url or "").strip()
raw_url_lower = raw_url.lower()
2025-12-22 02:11:53 -08:00
try:
2026-01-01 20:37:27 -08:00
parsed = urlparse(raw_url)
2025-12-22 02:11:53 -08:00
host = (parsed.hostname or "").strip().lower()
2025-12-27 03:13:16 -08:00
path = (parsed.path or "").strip()
2025-12-22 02:11:53 -08:00
except Exception:
host = ""
2025-12-27 03:13:16 -08:00
path = ""
2025-12-22 02:11:53 -08:00
2026-01-04 02:23:50 -08:00
def _norm_host(h: str) -> str:
h_norm = str(h or "").strip().lower()
if h_norm.startswith("www."):
h_norm = h_norm[4:]
return h_norm
host_norm = _norm_host(host)
if host_norm:
if host_norm == "openlibrary.org" or host_norm.endswith(".openlibrary.org"):
2026-01-05 07:51:19 -08:00
return "openlibrary" if REGISTRY.has_name("openlibrary") else None
2026-01-01 20:37:27 -08:00
2026-01-04 02:23:50 -08:00
if host_norm == "archive.org" or host_norm.endswith(".archive.org"):
2026-01-01 20:37:27 -08:00
low_path = str(path or "").lower()
is_borrowish = (
2026-01-05 07:51:19 -08:00
low_path.startswith("/borrow/")
or low_path.startswith("/stream/")
or low_path.startswith("/services/loans/")
or "/services/loans/" in low_path
2026-01-01 20:37:27 -08:00
)
if is_borrowish:
2026-01-05 07:51:19 -08:00
return "openlibrary" if REGISTRY.has_name("openlibrary") else None
return "internetarchive" if REGISTRY.has_name("internetarchive") else None
2025-12-27 03:13:16 -08:00
for info in REGISTRY.iter_plugins():
domains = _plugin_url_patterns(info.plugin_class)
2026-01-01 20:37:27 -08:00
if not domains:
2025-12-22 02:11:53 -08:00
continue
2026-01-05 07:51:19 -08:00
for domain in domains:
dom_raw = str(domain or "").strip()
2026-01-04 02:23:50 -08:00
dom = dom_raw.lower()
2025-12-22 02:11:53 -08:00
if not dom:
continue
2026-01-31 23:22:30 -08:00
if "://" in dom or dom.startswith("magnet:") or dom.endswith(":") or "🧲" in dom:
2026-01-04 02:23:50 -08:00
if raw_url_lower.startswith(dom):
2026-01-05 07:51:19 -08:00
return info.canonical_name
2026-01-01 20:37:27 -08:00
continue
2026-01-04 02:23:50 -08:00
dom_norm = _norm_host(dom)
if not dom_norm or not host_norm:
continue
if host_norm == dom_norm or host_norm.endswith("." + dom_norm):
2026-01-05 07:51:19 -08:00
return info.canonical_name
2025-12-22 02:11:53 -08:00
return None
def plugin_inline_query_choices(
plugin_name: str,
field_name: str,
config: Optional[Dict[str, Any]] = None,
) -> List[str]:
"""Return plugin-declared inline query choices for a field (e.g., system:GBA).
Plugins can expose a mapping via ``QUERY_ARG_CHOICES`` (preferred) or
``INLINE_QUERY_FIELD_CHOICES`` / ``inline_query_field_choices()``. The helper
keeps completion logic simple and reusable.
"""
pname = str(plugin_name or "").strip().lower()
field = str(field_name or "").strip().lower()
if not pname or not field:
return []
try:
2026-04-26 15:08:35 -07:00
mapping: Dict[str, List[Dict[str, Any]]] = {}
info = REGISTRY.get(pname)
if info is not None:
mapping = _collect_inline_choice_mapping(info.plugin_class)
2026-04-26 15:08:35 -07:00
if not mapping:
plugin = get_plugin(pname, config)
2026-04-26 15:08:35 -07:00
if plugin is None:
return []
mapping = _collect_inline_choice_mapping(plugin)
if not mapping:
return []
entries = mapping.get(field, [])
if not entries:
return []
seen: set[str] = set()
out: List[str] = []
for entry in entries:
text = entry.get("text") or entry.get("value")
if not text:
continue
text_str = str(text)
if text_str in seen:
continue
seen.add(text_str)
out.append(text_str)
for alias in entry.get("aliases", []):
alias_str = str(alias)
if alias_str and alias_str not in seen:
seen.add(alias_str)
out.append(alias_str)
return out
except Exception:
return []
def get_plugin_for_url(url: str,
config: Optional[Dict[str, Any]] = None) -> Optional[Provider]:
name = match_plugin_name_for_url(url)
2025-12-22 02:11:53 -08:00
if not name:
return None
return get_plugin(name, config)
def list_selection_url_prefixes() -> List[str]:
prefixes: List[str] = []
seen: set[str] = set()
for info in REGISTRY.iter_plugins():
try:
values = info.plugin_class.selection_url_prefixes()
except Exception:
values = ()
for value in values or ():
try:
normalized = str(value or "").strip().lower()
except Exception:
continue
if not normalized or normalized in seen:
continue
seen.add(normalized)
prefixes.append(normalized)
return prefixes
2025-12-22 02:11:53 -08:00
def resolve_inline_filters(
provider: Provider,
inline_args: Dict[str, Any],
*,
field_transforms: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]:
"""Map inline query args to provider filter values using declared choices.
- Uses provider's inline choice mapping (value/text/aliases) to resolve user text.
- Applies optional per-field transforms (e.g., str.upper).
- Returns normalized filters suitable for provider.search.
"""
filters: Dict[str, str] = {}
if not inline_args:
return filters
2026-02-11 18:16:07 -08:00
mapping = _collect_inline_choice_mapping(provider)
transforms = field_transforms or {}
for raw_key, raw_val in inline_args.items():
if raw_val is None:
continue
key = str(raw_key or "").strip().lower()
val_str = str(raw_val).strip()
if not key or not val_str:
continue
entries = mapping.get(key, [])
resolved: Optional[str] = None
val_lower = val_str.lower()
for entry in entries:
text = str(entry.get("text") or "").strip()
value = str(entry.get("value") or "").strip()
aliases = [str(a).strip() for a in entry.get("aliases", []) if a is not None]
if val_lower in {text.lower(), value.lower()} or val_lower in {a.lower() for a in aliases}:
resolved = value or text or val_str
break
if resolved is None:
resolved = val_str
transform = transforms.get(key)
if callable(transform):
try:
resolved = transform(resolved)
except Exception:
pass
if resolved:
filters[key] = str(resolved)
return filters
def clear_plugin_cache() -> None:
"""Clear the plugin instance cache. Useful for testing or config reloads."""
global _plugin_instance_cache
with _plugin_cache_lock:
_plugin_instance_cache.clear()
2025-12-11 19:04:02 -08:00
__all__ = [
"PluginInfo",
2025-12-19 02:29:42 -08:00
"Provider",
2026-01-05 07:51:19 -08:00
"SearchResult",
"PluginRegistry",
"PLUGIN_REGISTRY",
"register_plugin",
"get_plugin",
"list_plugins",
"get_plugin_with_capability",
"list_plugins_with_capability",
"list_plugin_names_with_capability",
2026-05-03 21:20:05 -07:00
"list_configured_plugin_names_with_capability",
"match_plugin_name_for_url",
"get_plugin_for_url",
"list_selection_url_prefixes",
"get_plugin_class",
2026-01-03 03:37:48 -08:00
"selection_auto_stage_for_table",
"plugin_inline_query_choices",
"is_known_plugin_name",
"clear_plugin_cache",
2025-12-11 19:04:02 -08:00
]