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
+232 -33
View File
@@ -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
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",