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.
"""