updating and refining plugin system refactor
This commit is contained in:
+232
-33
@@ -114,7 +114,7 @@ def parse_inline_query_arguments(raw_query: str) -> Tuple[str, Dict[str, str]]:
|
||||
class Provider(ABC):
|
||||
"""Unified plugin base class.
|
||||
|
||||
This replaces the older split between search and upload providers.
|
||||
This replaces the older split between search and upload plugins.
|
||||
Concrete plugins may implement any subset of:
|
||||
- search(query, ...)
|
||||
- download(result, output_dir)
|
||||
@@ -127,8 +127,8 @@ class Provider(ABC):
|
||||
PLUGIN_NAME: str = ""
|
||||
PLUGIN_ALIASES: Sequence[str] = ()
|
||||
|
||||
# Optional provider-driven defaults for what to do when a user selects @N from a
|
||||
# provider table. The CLI uses this to auto-insert stages (e.g. download-file)
|
||||
# Optional plugin-driven defaults for what to do when a user selects @N from a
|
||||
# plugin table. The CLI uses this to auto-insert stages (e.g. download-file)
|
||||
# without hardcoding table names.
|
||||
#
|
||||
# Example:
|
||||
@@ -138,7 +138,7 @@ class Provider(ABC):
|
||||
TABLE_AUTO_PREFIXES: Dict[str, Sequence[str]] = {}
|
||||
AUTO_STAGE_USE_SELECTION_ARGS: bool = False
|
||||
|
||||
# Optional provider-declared configuration keys.
|
||||
# Optional plugin-declared configuration keys.
|
||||
# Used for dynamically generating config panels (e.g., missing credentials).
|
||||
REQUIRED_CONFIG_KEYS: Sequence[str] = ()
|
||||
|
||||
@@ -176,7 +176,7 @@ class Provider(ABC):
|
||||
return False
|
||||
|
||||
def get_table_type(self, query: str, filters: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Return the table type identifier for results from this provider."""
|
||||
"""Return the table type identifier for results from this plugin."""
|
||||
return self.name
|
||||
|
||||
def get_table_title(self, query: str, filters: Optional[Dict[str, Any]] = None) -> str:
|
||||
@@ -197,12 +197,12 @@ class Provider(ABC):
|
||||
|
||||
@property
|
||||
def prefers_transfer_progress(self) -> bool:
|
||||
"""True if this provider prefers explicit transfer progress tracking (begin/finish) during download."""
|
||||
"""True if this plugin prefers explicit transfer progress tracking (begin/finish) during download."""
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def config_schema(cls) -> List[Dict[str, Any]]:
|
||||
"""Return configuration schema for this provider.
|
||||
"""Return configuration schema for this plugin.
|
||||
|
||||
Returns a list of dicts, each defining a field:
|
||||
{
|
||||
@@ -234,8 +234,124 @@ class Provider(ABC):
|
||||
return []
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def plugin_config_key(cls) -> str:
|
||||
return str(getattr(cls, "PLUGIN_NAME", None) or cls.__name__ or "").strip().lower()
|
||||
|
||||
@classmethod
|
||||
def plugin_instance_filter_keys(cls) -> Tuple[str, ...]:
|
||||
return ("instance", "store")
|
||||
|
||||
@classmethod
|
||||
def plugin_config_field_keys(cls) -> set[str]:
|
||||
keys: set[str] = set()
|
||||
try:
|
||||
for field in cls.config_schema() or []:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
key = str(field.get("key") or "").strip().lower()
|
||||
if key:
|
||||
keys.add(key)
|
||||
except Exception:
|
||||
return set()
|
||||
return keys
|
||||
|
||||
def requested_instance_name(
|
||||
self,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Optional[str]:
|
||||
for key in self.plugin_instance_filter_keys():
|
||||
value = kwargs.get(key)
|
||||
if value in (None, "") and isinstance(filters, dict):
|
||||
value = filters.get(key)
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
def plugin_config_root(self) -> Dict[str, Any]:
|
||||
if not isinstance(self.config, dict):
|
||||
return {}
|
||||
provider_cfg = self.config.get("provider")
|
||||
if not isinstance(provider_cfg, dict):
|
||||
return {}
|
||||
entry = provider_cfg.get(self.plugin_config_key())
|
||||
return dict(entry) if isinstance(entry, dict) else {}
|
||||
|
||||
def plugin_instance_configs(self) -> Dict[str, Dict[str, Any]]:
|
||||
entry = self.plugin_config_root()
|
||||
if not entry:
|
||||
return {}
|
||||
|
||||
schema_keys = self.plugin_config_field_keys()
|
||||
entry_keys = {str(key or "").strip().lower() for key in entry.keys()}
|
||||
looks_like_single = bool(schema_keys and entry_keys.intersection(schema_keys))
|
||||
if not looks_like_single and entry:
|
||||
looks_like_single = not all(isinstance(value, dict) for value in entry.values())
|
||||
|
||||
if looks_like_single:
|
||||
return {"default": dict(entry)}
|
||||
|
||||
instances: Dict[str, Dict[str, Any]] = {}
|
||||
for raw_name, raw_value in entry.items():
|
||||
if not isinstance(raw_value, dict):
|
||||
continue
|
||||
name = str(raw_name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
instances[name] = dict(raw_value)
|
||||
return instances
|
||||
|
||||
def configured_instances(self) -> List[str]:
|
||||
instances = self.plugin_instance_configs()
|
||||
if not instances:
|
||||
return []
|
||||
if set(instances.keys()) == {"default"}:
|
||||
return []
|
||||
return list(instances.keys())
|
||||
|
||||
def resolve_plugin_instance(
|
||||
self,
|
||||
instance_name: Optional[str] = None,
|
||||
*,
|
||||
require_explicit: bool = False,
|
||||
) -> Tuple[Optional[str], Dict[str, Any]]:
|
||||
instances = self.plugin_instance_configs()
|
||||
if not instances:
|
||||
return None, {}
|
||||
|
||||
requested = str(instance_name or "").strip()
|
||||
if not requested:
|
||||
first_name = next(iter(instances.keys()))
|
||||
resolved = dict(instances[first_name])
|
||||
if first_name != "default":
|
||||
resolved.setdefault("_instance_name", first_name)
|
||||
return (None if first_name == "default" else first_name), resolved
|
||||
|
||||
requested_lower = requested.lower()
|
||||
for name, cfg in instances.items():
|
||||
aliases = {str(name).strip().lower()}
|
||||
explicit_name = str(cfg.get("_instance_name") or cfg.get("instance") or cfg.get("name") or "").strip().lower()
|
||||
if explicit_name:
|
||||
aliases.add(explicit_name)
|
||||
if requested_lower in aliases:
|
||||
resolved = dict(cfg)
|
||||
if name != "default":
|
||||
resolved.setdefault("_instance_name", name)
|
||||
return (None if name == "default" else name), resolved
|
||||
|
||||
if require_explicit:
|
||||
return None, {}
|
||||
|
||||
first_name = next(iter(instances.keys()))
|
||||
resolved = dict(instances[first_name])
|
||||
if first_name != "default":
|
||||
resolved.setdefault("_instance_name", first_name)
|
||||
return (None if first_name == "default" else first_name), resolved
|
||||
|
||||
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Allow providers to normalize query text and parse inline arguments."""
|
||||
"""Allow plugins to normalize query text and parse inline arguments."""
|
||||
|
||||
normalized = str(query or "").strip()
|
||||
return normalized, {}
|
||||
@@ -250,9 +366,9 @@ class Provider(ABC):
|
||||
table_type: str = "",
|
||||
table_meta: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[List[SearchResult], Optional[str], Optional[Dict[str, Any]]]:
|
||||
"""Optional hook for provider-specific result transforms.
|
||||
"""Optional hook for plugin-specific result transforms.
|
||||
|
||||
Cmdlets should avoid hardcoding provider quirks. Providers can override
|
||||
Cmdlets should avoid hardcoding plugin quirks. Plugins can override
|
||||
this to:
|
||||
- expand/replace result sets (e.g., artist -> albums)
|
||||
- override the table type
|
||||
@@ -282,7 +398,7 @@ class Provider(ABC):
|
||||
**kwargs: Any,
|
||||
) -> List[SearchResult]:
|
||||
"""Search for items matching the query."""
|
||||
raise NotImplementedError(f"Provider '{self.name}' does not support search")
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support search")
|
||||
|
||||
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
|
||||
"""Download an item from a search result."""
|
||||
@@ -355,7 +471,7 @@ class Provider(ABC):
|
||||
}
|
||||
|
||||
def handle_url(self, url: str, *, output_dir: Optional[Path] = None) -> Tuple[bool, Optional[Path | Dict[str, Any]]]:
|
||||
"""Optional provider override to parse and act on URLs."""
|
||||
"""Optional plugin override to parse and act on URLs."""
|
||||
|
||||
_ = url
|
||||
_ = output_dir
|
||||
@@ -428,24 +544,24 @@ class Provider(ABC):
|
||||
return ""
|
||||
|
||||
def config_actions(self) -> List[Dict[str, Any]]:
|
||||
"""Optional actions exposed in the config editor for this provider."""
|
||||
"""Optional actions exposed in the config editor for this plugin."""
|
||||
|
||||
return []
|
||||
|
||||
def run_config_action(self, action_id: str, **_kwargs: Any) -> Dict[str, Any]:
|
||||
"""Execute a provider-owned config action from the config editor."""
|
||||
"""Execute a plugin-owned config action from the config editor."""
|
||||
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Provider '{self.name}' does not support config action '{action_id}'.",
|
||||
"message": f"Plugin '{self.name}' does not support config action '{action_id}'.",
|
||||
}
|
||||
|
||||
def upload(self, file_path: str, **kwargs: Any) -> str:
|
||||
"""Upload a file and return a URL or identifier."""
|
||||
raise NotImplementedError(f"Provider '{self.name}' does not support upload")
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support upload")
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Check if provider is available and properly configured."""
|
||||
"""Check if the plugin is available and properly configured."""
|
||||
|
||||
return True
|
||||
|
||||
@@ -459,7 +575,7 @@ class Provider(ABC):
|
||||
) -> bool:
|
||||
"""Optional hook for handling `@N` selection semantics.
|
||||
|
||||
The CLI can delegate selection behavior to a provider/store instead of
|
||||
The CLI can delegate selection behavior to a plugin/store instead of
|
||||
applying the default selection filtering.
|
||||
|
||||
Return True if the selection was handled and default behavior should be skipped.
|
||||
@@ -470,6 +586,101 @@ class Provider(ABC):
|
||||
_ = stage_is_last
|
||||
return False
|
||||
|
||||
def show_selection_details(
|
||||
self,
|
||||
selected_items: List[Any],
|
||||
*,
|
||||
ctx: Any,
|
||||
stage_is_last: bool = True,
|
||||
source_command: str = "",
|
||||
table_type: str = "",
|
||||
table_metadata: Optional[Dict[str, Any]] = None,
|
||||
**_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Optionally render a terminal detail view for a selected plugin row."""
|
||||
|
||||
_selected_item, payload, _metadata = self.resolve_selection_detail_subject(
|
||||
selected_items,
|
||||
stage_is_last=stage_is_last,
|
||||
source_command=source_command,
|
||||
)
|
||||
_ = table_type
|
||||
_ = table_metadata
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
|
||||
detail_title = self.label
|
||||
item_title = str(payload.get("title") or "").strip()
|
||||
if item_title:
|
||||
detail_title = f"{self.label}: {item_title}"
|
||||
|
||||
try:
|
||||
from SYS.rich_display import render_item_details_panel
|
||||
|
||||
render_item_details_panel(payload, title=detail_title)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
if hasattr(ctx, "set_last_result_items_only"):
|
||||
ctx.set_last_result_items_only([payload])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def resolve_selection_detail_subject(
|
||||
self,
|
||||
selected_items: List[Any],
|
||||
*,
|
||||
stage_is_last: bool = True,
|
||||
source_command: str = "",
|
||||
require_media_kind: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
"""Normalize a terminal `@N` selection into `(item, payload, metadata)`.
|
||||
|
||||
Custom plugin detail hooks can use this to share the common preconditions
|
||||
for item panels instead of re-checking terminal/single-row/search-file
|
||||
state in each plugin.
|
||||
"""
|
||||
|
||||
if not stage_is_last or len(selected_items or []) != 1:
|
||||
return None, None, None
|
||||
|
||||
normalized_source = str(source_command or "").replace("_", "-").strip().lower()
|
||||
if normalized_source != "search-file":
|
||||
return None, None, None
|
||||
|
||||
item: Any = selected_items[0]
|
||||
payload: Optional[Dict[str, Any]]
|
||||
if isinstance(item, dict):
|
||||
payload = item
|
||||
else:
|
||||
payload = None
|
||||
to_dict = getattr(item, "to_dict", None)
|
||||
if callable(to_dict):
|
||||
try:
|
||||
maybe = to_dict()
|
||||
except Exception:
|
||||
maybe = None
|
||||
if isinstance(maybe, dict):
|
||||
payload = maybe
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return item, None, None
|
||||
|
||||
meta: Dict[str, Any] = {}
|
||||
nested = payload.get("full_metadata") or payload.get("metadata")
|
||||
if isinstance(nested, dict):
|
||||
meta = nested
|
||||
|
||||
if require_media_kind:
|
||||
media_kind = str(payload.get("media_kind") or meta.get("media_kind") or "").strip().lower()
|
||||
if media_kind != str(require_media_kind or "").strip().lower():
|
||||
return item, None, None
|
||||
|
||||
return item, payload, meta
|
||||
|
||||
@classmethod
|
||||
def selection_auto_stage(
|
||||
cls,
|
||||
@@ -478,10 +689,10 @@ class Provider(ABC):
|
||||
) -> Optional[List[str]]:
|
||||
"""Return a stage to auto-run after selecting from `table_type`.
|
||||
|
||||
This is used by the CLI to auto-insert default stages for provider tables
|
||||
This is used by the CLI to auto-insert default stages for plugin tables
|
||||
(e.g. select a YouTube row -> auto-run download-file).
|
||||
|
||||
Providers can implement this via class attributes (TABLE_AUTO_STAGES /
|
||||
Plugins can implement this via class attributes (TABLE_AUTO_STAGES /
|
||||
TABLE_AUTO_PREFIXES) or by overriding this method.
|
||||
"""
|
||||
t = str(table_type or "").strip().lower()
|
||||
@@ -522,7 +733,7 @@ class Provider(ABC):
|
||||
|
||||
@classmethod
|
||||
def url_patterns(cls) -> Tuple[str, ...]:
|
||||
"""Return normalized URL patterns that this provider handles."""
|
||||
"""Return normalized URL patterns that this plugin handles."""
|
||||
patterns: List[str] = []
|
||||
maybe_urls = getattr(cls, "URL", None)
|
||||
if isinstance(maybe_urls, (list, tuple)):
|
||||
@@ -564,15 +775,3 @@ class Provider(ABC):
|
||||
return tuple(prefixes)
|
||||
|
||||
|
||||
class SearchProvider(Provider):
|
||||
"""Compatibility alias for older code.
|
||||
|
||||
Prefer inheriting from Provider directly.
|
||||
"""
|
||||
|
||||
|
||||
class FileProvider(Provider):
|
||||
"""Compatibility alias for older code.
|
||||
|
||||
Prefer inheriting from Provider directly.
|
||||
"""
|
||||
|
||||
+91
-100
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user