cleanup and rename provider to plugin

This commit is contained in:
2026-05-21 16:19:17 -07:00
parent 02d84f423e
commit e8913d1344
62 changed files with 553 additions and 165 deletions
+4 -4
View File
@@ -98,7 +98,7 @@ from SYS.result_table import Table
from SYS.worker import WorkerManagerRegistry, WorkerStages, WorkerOutputMirror, WorkerStageSession from SYS.worker import WorkerManagerRegistry, WorkerStages, WorkerOutputMirror, WorkerStageSession
from SYS.pipeline import PipelineExecutor from SYS.pipeline import PipelineExecutor
from ProviderCore.registry import plugin_inline_query_choices from PluginCore.registry import plugin_inline_query_choices
@@ -505,7 +505,7 @@ class CmdletIntrospection:
if normalized_arg == "plugin": if normalized_arg == "plugin":
canonical_cmd = (cmd_name or "").replace("_", "-").lower() canonical_cmd = (cmd_name or "").replace("_", "-").lower()
try: try:
from ProviderCore.registry import ( from PluginCore.registry import (
list_configured_plugin_names_with_capability, list_configured_plugin_names_with_capability,
list_plugin_names_for_cmdlet, list_plugin_names_for_cmdlet,
) )
@@ -546,7 +546,7 @@ class CmdletIntrospection:
if normalized_arg == "scrape": if normalized_arg == "scrape":
try: try:
from plugins.metadata_provider import list_metadata_plugins from plugins.metadata_plugin import list_metadata_plugins
metadata_plugins = list_metadata_plugins(config) or {} metadata_plugins = list_metadata_plugins(config) or {}
if metadata_plugins: if metadata_plugins:
@@ -765,7 +765,7 @@ class CmdletCompleter(Completer):
return [] return []
try: try:
from ProviderCore.registry import get_plugin_class from PluginCore.registry import get_plugin_class
except Exception: except Exception:
return [] return []
@@ -22,7 +22,7 @@ from urllib.parse import urlparse
from SYS.logger import log, debug from SYS.logger import log, debug
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
_EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH") _EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH")
-5
View File
@@ -1,5 +0,0 @@
"""Plugin core modules.
This package contains the plugin framework (base types, registry, and shared
helpers). Bundled plugins live in the `plugins/` package.
"""
+2 -2
View File
@@ -6,8 +6,8 @@ from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import logging import logging
from ProviderCore.commands import get_primary_command_object from PluginCore.commands import get_primary_command_object
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
+1 -1
View File
@@ -279,7 +279,7 @@ def extract_records(doc_or_html: Any, base_url: Optional[str] = None, xpaths: Op
# Small convenience: convert records to SearchResult. Providers can call this or # Small convenience: convert records to SearchResult. Providers can call this or
# use their own mapping when they need full SearchResult objects. # use their own mapping when they need full SearchResult objects.
from ProviderCore.base import SearchResult # local import to avoid circular issues from PluginCore.base import SearchResult # local import to avoid circular issues
def records_to_search_results(records: List[Dict[str, str]], table: str = "provider") -> List[SearchResult]: def records_to_search_results(records: List[Dict[str, str]], table: str = "provider") -> List[SearchResult]:
+1 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
from SYS.yt_metadata import extract_ytdlp_tags from SYS.yt_metadata import extract_ytdlp_tags
try: # Optional; used when available for richer metadata fetches try: # Optional; used when available for richer metadata fetches
+4 -4
View File
@@ -1562,7 +1562,7 @@ class PipelineExecutor:
_add(getattr(item, "table", None)) _add(getattr(item, "table", None))
try: try:
from ProviderCore.registry import get_plugin, is_known_plugin_name from PluginCore.registry import get_plugin, is_known_plugin_name
except Exception: except Exception:
get_plugin = None # type: ignore get_plugin = None # type: ignore
is_known_plugin_name = None # type: ignore is_known_plugin_name = None # type: ignore
@@ -1679,7 +1679,7 @@ class PipelineExecutor:
_add(getattr(item, "source", None)) _add(getattr(item, "source", None))
try: try:
from ProviderCore.registry import get_plugin, is_known_plugin_name from PluginCore.registry import get_plugin, is_known_plugin_name
except Exception: except Exception:
return None return None
@@ -2313,7 +2313,7 @@ class PipelineExecutor:
auto_stage = None auto_stage = None
if isinstance(table_type, str) and table_type: if isinstance(table_type, str) and table_type:
try: try:
from ProviderCore.registry import selection_auto_stage_for_table from PluginCore.registry import selection_auto_stage_for_table
auto_stage = selection_auto_stage_for_table(table_type) auto_stage = selection_auto_stage_for_table(table_type)
except Exception: except Exception:
@@ -3098,7 +3098,7 @@ class PipelineExecutor:
auto_stage = None auto_stage = None
if isinstance(table_type, str) and table_type: if isinstance(table_type, str) and table_type:
try: try:
from ProviderCore.registry import selection_auto_stage_for_table from PluginCore.registry import selection_auto_stage_for_table
# Preserve historical behavior: only forward selection-stage args # Preserve historical behavior: only forward selection-stage args
# to the auto stage when we are appending a new last stage. # to the auto stage when we are appending a new last stage.
+3 -3
View File
@@ -6,7 +6,7 @@ import pkgutil
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, Iterable, List, Optional
from SYS.config import global_config from SYS.config import global_config
from ProviderCore.registry import get_plugin_class, list_plugins from PluginCore.registry import get_plugin_class, list_plugins
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -194,7 +194,7 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]:
def get_configurable_store_types() -> List[str]: def get_configurable_store_types() -> List[str]:
"""Return configurable multi-instance plugin types (formerly 'store types').""" """Return configurable multi-instance plugin types (formerly 'store types')."""
from ProviderCore.registry import REGISTRY from PluginCore.registry import REGISTRY
options: List[str] = [] options: List[str] = []
for info in REGISTRY.iter_plugins(): for info in REGISTRY.iter_plugins():
plugin_cls = info.plugin_class plugin_cls = info.plugin_class
@@ -205,7 +205,7 @@ def get_configurable_store_types() -> List[str]:
def get_configurable_plugin_types() -> List[str]: def get_configurable_plugin_types() -> List[str]:
"""Return all plugin types that can be configured: those with a schema or MULTI_INSTANCE flag.""" """Return all plugin types that can be configured: those with a schema or MULTI_INSTANCE flag."""
from ProviderCore.registry import REGISTRY from PluginCore.registry import REGISTRY
options: List[str] = [] options: List[str] = []
for info in REGISTRY.iter_plugins(): for info in REGISTRY.iter_plugins():
plugin_cls = info.plugin_class plugin_cls = info.plugin_class
@@ -1,12 +1,12 @@
"""Convenience mixins and helpers for table-based providers. """Convenience mixins and helpers for table-based plugins.
Provides a small `TableProviderMixin` that handles HTTP fetch + table extraction Provides a small `TablePluginMixin` that handles HTTP fetch + table extraction
(using `SYS.html_table.extract_records`) and converts records into (using `SYS.html_table.extract_records`) and converts records into
`ProviderCore.base.SearchResult` rows with sane default column ordering. `PluginCore.base.SearchResult` rows with sane default column ordering.
Providers can subclass this mixin to implement search quickly: Plugins can subclass this mixin to implement search quickly:
class MyProvider(TableProviderMixin, Provider): class MyPlugin(TablePluginMixin, Provider):
URL = ("https://example.org/search",) URL = ("https://example.org/search",)
def search(self, query, limit=50, **kwargs): def search(self, query, limit=50, **kwargs):
@@ -21,7 +21,7 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from API.HTTP import HTTPClient from API.HTTP import HTTPClient
from ProviderCore.base import SearchResult from PluginCore.base import SearchResult
from SYS.html_table import extract_records from SYS.html_table import extract_records
import lxml.html as lxml_html import lxml.html as lxml_html
@@ -29,8 +29,8 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TableProviderMixin: class TablePluginMixin:
"""Mixin to simplify providers that scrape table/list results from HTML. """Mixin to simplify plugins that scrape table/list results from HTML.
Methods: Methods:
- search_table_from_url(url, limit, xpaths): fetches HTML, extracts records, returns SearchResults - search_table_from_url(url, limit, xpaths): fetches HTML, extracts records, returns SearchResults
@@ -59,7 +59,7 @@ class TableProviderMixin:
resp = client.get(url) resp = client.get(url)
content = resp.content content = resp.content
except Exception: except Exception:
logger.exception("Failed to fetch URL %s for provider %s", url, getattr(self, 'name', '<provider>')) logger.exception("Failed to fetch URL %s for plugin %s", url, getattr(self, 'name', '<plugin>'))
return [] return []
# Ensure we pass an lxml document or string (httpx returns bytes) # Ensure we pass an lxml document or string (httpx returns bytes)
@@ -99,14 +99,14 @@ class TableProviderMixin:
results.append( results.append(
SearchResult( SearchResult(
table=(getattr(self, "name", "provider") or "provider"), table=(getattr(self, "name", "plugin") or "plugin"),
title=title, title=title,
path=path, path=path,
detail="", detail="",
annotations=[], annotations=[],
media_kind="file", media_kind="file",
size_bytes=None, size_bytes=None,
tag={getattr(self, "name", "provider")}, tag={getattr(self, "name", "plugin") or "plugin"},
columns=cols, columns=cols,
full_metadata={"raw_record": rec}, full_metadata={"raw_record": rec},
) )
+2 -2
View File
@@ -88,7 +88,7 @@ def _load_root_modules() -> None:
def _load_helper_modules() -> None: def _load_helper_modules() -> None:
# Provider-specific module pre-loading removed; providers are loaded lazily # Provider-specific module pre-loading removed; providers are loaded lazily
# through ProviderCore.registry when first referenced. # through PluginCore.registry when first referenced.
# #
# Keep explicit imports for cmdlets moved into subpackages so they remain # Keep explicit imports for cmdlets moved into subpackages so they remain
# registered under their legacy command names. # registered under their legacy command names.
@@ -118,7 +118,7 @@ def _register_native_commands() -> None:
def _register_plugin_commands() -> None: def _register_plugin_commands() -> None:
try: try:
from ProviderCore.commands import register_plugin_commands from PluginCore.commands import register_plugin_commands
except Exception: except Exception:
return return
try: try:
+3 -3
View File
@@ -276,7 +276,7 @@ class SharedArgs:
# Plugin-based multi-instance backends (config["plugin"] / config["provider"] sections) # Plugin-based multi-instance backends (config["plugin"] / config["provider"] sections)
try: try:
from ProviderCore.registry import REGISTRY from PluginCore.registry import REGISTRY
plugin_instances = REGISTRY.list_storage_plugin_instances(config) plugin_instances = REGISTRY.list_storage_plugin_instances(config)
for _plugin_name, instance_names in plugin_instances.items(): for _plugin_name, instance_names in plugin_instances.items():
names.update(instance_names) names.update(instance_names)
@@ -1448,7 +1448,7 @@ def fetch_hydrus_metadata(
client = hydrus_client client = hydrus_client
hydrus_provider = None hydrus_provider = None
try: try:
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
hydrus_provider = get_plugin("hydrusnetwork", config) hydrus_provider = get_plugin("hydrusnetwork", config)
except Exception: except Exception:
@@ -4177,7 +4177,7 @@ def check_url_exists_in_storage(
hydrus_provider = None hydrus_provider = None
try: try:
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
hydrus_provider = get_plugin("hydrusnetwork", config) hydrus_provider = get_plugin("hydrusnetwork", config)
except Exception: except Exception:
+3 -3
View File
@@ -63,7 +63,7 @@ class _CommandDependencies:
def get_plugin(self, name: str) -> Optional[Any]: def get_plugin(self, name: str) -> Optional[Any]:
"""Cached plugin lookup by name.""" """Cached plugin lookup by name."""
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
norm_name = str(name or "").strip().lower() norm_name = str(name or "").strip().lower()
if not norm_name: if not norm_name:
@@ -77,7 +77,7 @@ class _CommandDependencies:
def get_plugin_with_capability(self, name: str, capability: str) -> Optional[Any]: def get_plugin_with_capability(self, name: str, capability: str) -> Optional[Any]:
"""Cached plugin lookup with capability check.""" """Cached plugin lookup with capability check."""
from ProviderCore.registry import get_plugin_with_capability from PluginCore.registry import get_plugin_with_capability
norm_name = str(name or "").strip().lower() norm_name = str(name or "").strip().lower()
if not norm_name: if not norm_name:
@@ -2336,7 +2336,7 @@ class Add_File(Cmdlet):
delete_after: bool, delete_after: bool,
) -> int: ) -> int:
"""Handle uploading via an upload plugin (e.g. 0x0).""" """Handle uploading via an upload plugin (e.g. 0x0)."""
from ProviderCore.registry import ( from PluginCore.registry import (
get_plugin_with_capability, get_plugin_with_capability,
list_plugin_names_with_capability, list_plugin_names_with_capability,
list_plugins_with_capability, list_plugins_with_capability,
+1 -1
View File
@@ -13,7 +13,7 @@ from typing import Any, Dict, List, Sequence, Set
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from SYS.logger import log from SYS.logger import log
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
from SYS.item_accessors import get_http_url, get_sha256_hex, get_store_name from SYS.item_accessors import get_http_url, get_sha256_hex, get_store_name
from SYS.utils import extract_hydrus_hash_from_url from SYS.utils import extract_hydrus_hash_from_url
+1 -1
View File
@@ -8,7 +8,7 @@ import sys
from pathlib import Path from pathlib import Path
from SYS.logger import debug, log from SYS.logger import debug, log
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
from Store import Store from Store import Store
from .. import _shared as sh from .. import _shared as sh
from SYS import pipeline as ctx from SYS import pipeline as ctx
+2 -2
View File
@@ -992,8 +992,8 @@ class Download_File(Cmdlet):
def _load_provider_registry() -> Dict[str, Any]: def _load_provider_registry() -> Dict[str, Any]:
"""Lightweight accessor for plugin helpers without hard dependencies.""" """Lightweight accessor for plugin helpers without hard dependencies."""
try: try:
from ProviderCore import registry as provider_registry # type: ignore from PluginCore import registry as provider_registry # type: ignore
from ProviderCore.base import SearchResult # type: ignore from PluginCore.base import SearchResult # type: ignore
return { return {
"get_plugin": getattr(provider_registry, "get_plugin", None), "get_plugin": getattr(provider_registry, "get_plugin", None),
+1 -1
View File
@@ -154,7 +154,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if urls_to_download and len(urls_to_download) >= 2: if urls_to_download and len(urls_to_download) >= 2:
try: try:
from ProviderCore.registry import get_plugin_for_url from PluginCore.registry import get_plugin_for_url
expanded: List[Dict[str, Any]] = [] expanded: List[Dict[str, Any]] = []
downloaded_any = False downloaded_any = False
+1 -1
View File
@@ -15,7 +15,7 @@ from urllib.parse import urlparse, parse_qs, unquote, urljoin
from SYS.logger import log, debug, debug_panel from SYS.logger import log, debug, debug_panel
from SYS.payload_builders import build_file_result_payload, normalize_file_extension from SYS.payload_builders import build_file_result_payload, normalize_file_extension
from ProviderCore.registry import get_plugin_with_capability, list_plugins_with_capability from PluginCore.registry import get_plugin_with_capability, list_plugins_with_capability
from SYS.rich_display import ( from SYS.rich_display import (
show_plugin_config_panel, show_plugin_config_panel,
show_store_config_panel, show_store_config_panel,
+1 -1
View File
@@ -5,7 +5,7 @@ import sys
from SYS.detail_view_helpers import create_detail_view, prepare_detail_metadata from SYS.detail_view_helpers import create_detail_view, prepare_detail_metadata
from SYS.logger import log from SYS.logger import log
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
from SYS.result_table_helpers import add_row_columns from SYS.result_table_helpers import add_row_columns
from SYS.selection_builder import build_hash_store_selection from SYS.selection_builder import build_hash_store_selection
from SYS.result_publication import publish_result_table from SYS.result_publication import publish_result_table
+1 -1
View File
@@ -9,7 +9,7 @@ import sys
from SYS.logger import log from SYS.logger import log
from SYS.item_accessors import get_sha256_hex, get_store_name from SYS.item_accessors import get_sha256_hex, get_store_name
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
from SYS import pipeline as ctx from SYS import pipeline as ctx
from .. import _shared as sh from .. import _shared as sh
+8 -8
View File
@@ -14,18 +14,18 @@ import sys
from SYS.logger import log, debug from SYS.logger import log, debug
# plugins.metadata_provider is deferred: it transitively loads yt_dlp, Cryptodome, # plugins.metadata_plugin is deferred: it transitively loads yt_dlp, Cryptodome,
# imdbinfo, musicbrainzngs and ~1400 modules (~1.5s). Import lazily on first use. # imdbinfo, musicbrainzngs and ~1400 modules (~1.5s). Import lazily on first use.
_METADATA_PROVIDER_MOD: Optional[Any] = None _METADATA_PLUGIN_MOD: Optional[Any] = None
def _mp() -> Any: def _mp() -> Any:
"""Return the (lazily imported) plugins.metadata_provider module.""" """Return the (lazily imported) plugins.metadata_plugin module."""
global _METADATA_PROVIDER_MOD global _METADATA_PLUGIN_MOD
if _METADATA_PROVIDER_MOD is None: if _METADATA_PLUGIN_MOD is None:
import plugins.metadata_provider as _m import plugins.metadata_plugin as _m
_METADATA_PROVIDER_MOD = _m _METADATA_PLUGIN_MOD = _m
return _METADATA_PROVIDER_MOD return _METADATA_PLUGIN_MOD
from pathlib import Path from pathlib import Path
@@ -42,7 +42,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
select_raw = parsed.get("select") select_raw = parsed.get("select")
try: try:
provider = get_plugin(plugin_name) plugin = get_plugin(plugin_name)
except Exception: except Exception:
log(f"Unknown plugin: {plugin_name}", file=sys.stderr) log(f"Unknown plugin: {plugin_name}", file=sys.stderr)
return 1 return 1
@@ -52,7 +52,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if use_sample: if use_sample:
# Try to locate SAMPLE_ITEMS in the adapter's module (convention only) # Try to locate SAMPLE_ITEMS in the adapter's module (convention only)
try: try:
mod = __import__(provider.adapter.__module__, fromlist=["*"]) mod = __import__(plugin.adapter.__module__, fromlist=["*"])
items = getattr(mod, "SAMPLE_ITEMS", None) items = getattr(mod, "SAMPLE_ITEMS", None)
if items is None: if items is None:
log("Plugin does not expose SAMPLE_ITEMS; no sample available", file=sys.stderr) log("Plugin does not expose SAMPLE_ITEMS; no sample available", file=sys.stderr)
@@ -69,14 +69,14 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
items = inputs items = inputs
try: try:
table = provider.build_table(items) table = plugin.build_table(items)
except Exception as exc: except Exception as exc:
log(f"Plugin '{provider.name}' failed: {exc}", file=sys.stderr) log(f"Plugin '{plugin.name}' failed: {exc}", file=sys.stderr)
return 1 return 1
# Emit rows for downstream pipeline consumption (pipable behavior). # Emit rows for downstream pipeline consumption (pipable behavior).
try: try:
for item in provider.serialize_rows(table.rows): for item in plugin.serialize_rows(table.rows):
try: try:
ctx.emit(item) ctx.emit(item)
except Exception: except Exception:
@@ -115,7 +115,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 1 return 1
selected = table.rows[select_idx] selected = table.rows[select_idx]
sel_args = provider.selection_args(selected) sel_args = plugin.selection_args(selected)
if not run_cmd: if not run_cmd:
# Print selection args for caller # Print selection args for caller
+1 -1
View File
@@ -41,7 +41,7 @@ def _provider_config_map(config: dict) -> dict[str, Any]:
def _iter_registered_plugin_infos() -> tuple[Any, ...]: def _iter_registered_plugin_infos() -> tuple[Any, ...]:
try: try:
from ProviderCore.registry import REGISTRY from PluginCore.registry import REGISTRY
return tuple( return tuple(
sorted( sorted(
+412 -16
View File
@@ -12,6 +12,13 @@ from SYS.config import (
) )
from SYS.database import LOG_DB_PATH, db from SYS.database import LOG_DB_PATH, db
from SYS.logger import log from SYS.logger import log
from SYS.plugin_config import (
build_default_plugin_config,
build_default_tool_config,
get_configurable_plugin_types,
get_configurable_store_types,
get_configurable_tool_types,
)
from SYS import pipeline as ctx from SYS import pipeline as ctx
from SYS.result_table import Table from SYS.result_table import Table
from cmdnat._parsing import ( from cmdnat._parsing import (
@@ -26,6 +33,7 @@ from cmdnat._parsing import (
_PREFERENCES_BROWSE_PATH = "__preferences__" _PREFERENCES_BROWSE_PATH = "__preferences__"
_PLUGINS_BROWSE_PATH = "__plugins__" _PLUGINS_BROWSE_PATH = "__plugins__"
_PLUGIN_CATEGORY_KEYS = ("plugin", "provider", "tool") _PLUGIN_CATEGORY_KEYS = ("plugin", "provider", "tool")
_CREATE_INSTANCE_FLAG = "-create-instance"
_KNOWN_SECTION_LABELS = { _KNOWN_SECTION_LABELS = {
"plugin": "Plugins", "plugin": "Plugins",
"provider": "Plugins", "provider": "Plugins",
@@ -52,6 +60,18 @@ _SENSITIVE_CONFIG_KEYS = {
"secret", "secret",
"token", "token",
} }
_CONFIG_ITEM_FIELDS = (
"kind",
"key",
"title",
"browse_path",
"name",
"value",
"value_display",
"type",
"display_path",
"instance_target",
)
CMDLET = Cmdlet( CMDLET = Cmdlet(
name=".config", name=".config",
@@ -271,6 +291,168 @@ def _format_config_entry_count(value: Any) -> str:
return f"{count} entries" return f"{count} entries"
def _get_configurable_plugin_names() -> List[str]:
try:
return [
str(name).strip().lower()
for name in (get_configurable_plugin_types() or [])
if str(name).strip()
]
except Exception:
return []
def _get_configurable_tool_names() -> List[str]:
try:
return [
str(name).strip().lower()
for name in (get_configurable_tool_types() or [])
if str(name).strip()
]
except Exception:
return []
def _get_multi_instance_plugin_names() -> set[str]:
try:
return {
str(name).strip().lower()
for name in (get_configurable_store_types() or [])
if str(name).strip()
}
except Exception:
return set()
def _split_config_path(value: Optional[str]) -> List[str]:
return [part for part in str(value or "").split(".") if part]
def _is_multi_instance_plugin_name(name: str) -> bool:
return str(name or "").strip().lower() in _get_multi_instance_plugin_names()
def _is_multi_instance_plugin_root_path(browse_path: Optional[str]) -> bool:
parts = _split_config_path(browse_path)
return (
len(parts) == 2
and parts[0] in {"plugin", "provider"}
and _is_multi_instance_plugin_name(parts[1])
)
def _plugin_schema_field_keys(plugin_name: str) -> set[str]:
defaults = build_default_plugin_config(plugin_name)
if not isinstance(defaults, dict):
return set()
return {
str(key or "").strip().lower()
for key in defaults.keys()
if str(key or "").strip()
}
def _looks_like_single_instance_branch(plugin_name: str, branch: Any) -> bool:
if not isinstance(branch, dict) or not branch:
return False
schema_keys = _plugin_schema_field_keys(plugin_name)
entry_keys = {str(key or "").strip().lower() for key in branch.keys()}
looks_like_single = bool(schema_keys and entry_keys.intersection(schema_keys))
if not looks_like_single:
looks_like_single = not all(isinstance(value, dict) for value in branch.values())
return looks_like_single
def _normalize_multi_instance_branch(plugin_name: str, branch: Any) -> Dict[str, Any]:
if not isinstance(branch, dict):
return {}
if _looks_like_single_instance_branch(plugin_name, branch):
return {"default": dict(branch)}
return {
str(key): value
for key, value in _visible_config_entries(branch)
if isinstance(value, dict)
}
def _build_create_instance_item(category: str, plugin_name: str) -> Dict[str, Any]:
target = f"{category}.{plugin_name}"
return {
"kind": "create_instance",
"key": f"{target}.__new_instance__",
"title": "Add Instance",
"name": "add_instance",
"value": None,
"value_display": "Create with @N | .config <name>",
"display_path": f"{_format_config_path_label(target)} / Add Instance",
"type": "action",
"instance_target": target,
}
def _build_synthetic_plugin_branch(category: str, name: str) -> Optional[Dict[str, Any]]:
normalized_category = str(category or "").strip().lower()
normalized_name = str(name or "").strip().lower()
if not normalized_name:
return None
if normalized_category == "tool":
branch = build_default_tool_config(normalized_name)
return dict(branch) if isinstance(branch, dict) else None
branch = build_default_plugin_config(normalized_name)
if not isinstance(branch, dict):
return None
if normalized_name in _get_multi_instance_plugin_names():
return {"default": dict(branch)}
return dict(branch)
def _find_configured_plugin_branch(
config_data: Dict[str, Any],
category: str,
name: str,
) -> Optional[tuple[str, Dict[str, Any]]]:
category_block = config_data.get(category)
if not isinstance(category_block, dict):
return None
target = str(name or "").strip().lower()
for raw_name, raw_value in _visible_config_entries(category_block):
if str(raw_name or "").strip().lower() != target or not isinstance(raw_value, dict):
continue
return raw_name, raw_value
return None
def _resolve_plugin_branch(
config_data: Dict[str, Any],
category: str,
name: str,
) -> Optional[tuple[str, Dict[str, Any], bool]]:
found = _find_configured_plugin_branch(config_data, category, name)
if found is not None:
resolved_name, resolved_value = found
return resolved_name, resolved_value, True
normalized_category = str(category or "").strip().lower()
normalized_name = str(name or "").strip().lower()
if not normalized_name:
return None
if normalized_category == "tool":
if normalized_name not in _get_configurable_tool_names():
return None
elif normalized_name not in _get_configurable_plugin_names():
return None
synthetic = _build_synthetic_plugin_branch(normalized_category, normalized_name)
if synthetic is None:
return None
return normalized_name, synthetic, False
def _iter_plugin_branches(config_data: Dict[str, Any]) -> List[tuple[str, str, Any]]: def _iter_plugin_branches(config_data: Dict[str, Any]) -> List[tuple[str, str, Any]]:
branches: List[tuple[str, str, Any]] = [] branches: List[tuple[str, str, Any]] = []
if not isinstance(config_data, dict): if not isinstance(config_data, dict):
@@ -285,9 +467,41 @@ def _iter_plugin_branches(config_data: Dict[str, Any]) -> List[tuple[str, str, A
return branches return branches
def _iter_available_plugin_branches(config_data: Dict[str, Any]) -> List[tuple[str, str, Any, bool]]:
branches: List[tuple[str, str, Any, bool]] = []
seen: set[str] = set()
for category, name, value in _iter_plugin_branches(config_data):
normalized_name = str(name or "").strip().lower()
if not normalized_name:
continue
branches.append((category, name, value, True))
seen.add(normalized_name)
for name in _get_configurable_plugin_names():
if name in seen:
continue
synthetic = _build_synthetic_plugin_branch("plugin", name)
if synthetic is None:
continue
branches.append(("plugin", name, synthetic, False))
seen.add(name)
for name in _get_configurable_tool_names():
if name in seen:
continue
synthetic = _build_synthetic_plugin_branch("tool", name)
if synthetic is None:
continue
branches.append(("tool", name, synthetic, False))
seen.add(name)
return branches
def _collect_plugin_root_items(config_data: Dict[str, Any]) -> List[Dict[str, Any]]: def _collect_plugin_root_items(config_data: Dict[str, Any]) -> List[Dict[str, Any]]:
plugin_items: Dict[str, Dict[str, Any]] = {} plugin_items: Dict[str, Dict[str, Any]] = {}
for category, name, value in _iter_plugin_branches(config_data): for category, name, value, is_configured in _iter_available_plugin_branches(config_data):
key = str(name or "").strip().lower() key = str(name or "").strip().lower()
if not key: if not key:
continue continue
@@ -299,7 +513,7 @@ def _collect_plugin_root_items(config_data: Dict[str, Any]) -> List[Dict[str, An
"browse_path": f"{category}.{name}", "browse_path": f"{category}.{name}",
"summary": _format_config_entry_count(value), "summary": _format_config_entry_count(value),
"type": "section", "type": "section",
"description": "Plugin configuration", "description": "Plugin configuration" if is_configured else "Plugin configuration (available to configure)",
} }
continue continue
@@ -337,8 +551,22 @@ def _resolve_config_branch(
for item in _collect_plugin_root_items(config_data) for item in _collect_plugin_root_items(config_data)
} }
parts = [part for part in text.split(".") if part]
if len(parts) >= 2 and parts[0] in _PLUGIN_CATEGORY_KEYS:
resolved = _resolve_plugin_branch(config_data, parts[0], parts[1])
if resolved is None:
return None
_, current, _ = resolved
if parts[0] in {"plugin", "provider"} and _is_multi_instance_plugin_name(parts[1]):
current = _normalize_multi_instance_branch(parts[1], current)
for part in parts[2:]:
if not isinstance(current, dict):
return None
current = current.get(part)
return current if isinstance(current, dict) else None
current: Any = config_data current: Any = config_data
for part in text.split("."): for part in parts:
if not isinstance(current, dict): if not isinstance(current, dict):
return None return None
current = current.get(part) current = current.get(part)
@@ -386,6 +614,66 @@ def _build_value_item(
} }
def _create_or_get_plugin_instance(
config_data: Dict[str, Any],
instance_target: str,
instance_name: str,
) -> tuple[str, bool]:
parts = _split_config_path(instance_target)
if len(parts) != 2 or parts[0] not in {"plugin", "provider"}:
raise ValueError(f"Unsupported instance target '{instance_target}'")
category, plugin_name = parts
raw_instance_name = str(instance_name or "").strip()
if not raw_instance_name:
raise ValueError("Instance name is required")
if raw_instance_name.startswith("_"):
raise ValueError("Instance names cannot start with '_' characters")
category_block = config_data.get(category)
if not isinstance(category_block, dict):
category_block = {}
config_data[category] = category_block
plugin_block = category_block.get(plugin_name)
if not isinstance(plugin_block, dict):
plugin_block = {}
category_block[plugin_name] = plugin_block
if _looks_like_single_instance_branch(plugin_name, plugin_block):
existing_default = dict(plugin_block)
plugin_block.clear()
plugin_block["default"] = existing_default
target_key = None
lowered_target = raw_instance_name.lower()
for existing_key in plugin_block.keys():
if str(existing_key or "").strip().lower() == lowered_target:
target_key = str(existing_key)
break
if target_key is not None and isinstance(plugin_block.get(target_key), dict):
return f"{category}.{plugin_name}.{target_key}", False
plugin_block[raw_instance_name] = dict(build_default_plugin_config(plugin_name))
return f"{category}.{plugin_name}.{raw_instance_name}", True
def _resolve_update_key(config_data: Dict[str, Any], selection_key: str) -> str:
parts = _split_config_path(selection_key)
if (
len(parts) >= 4
and parts[0] in {"plugin", "provider"}
and parts[2].lower() == "default"
and _is_multi_instance_plugin_name(parts[1])
):
category_block = config_data.get(parts[0])
plugin_block = category_block.get(parts[1]) if isinstance(category_block, dict) else None
if _looks_like_single_instance_branch(parts[1], plugin_block):
return ".".join([parts[0], parts[1], *parts[3:]])
return selection_key
def _build_root_config_items(config_data: Dict[str, Any]) -> List[Dict[str, Any]]: def _build_root_config_items(config_data: Dict[str, Any]) -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []
visible_entries = _visible_config_entries(config_data) visible_entries = _visible_config_entries(config_data)
@@ -444,7 +732,13 @@ def _build_nested_config_items(
section_items: List[Dict[str, Any]] = [] section_items: List[Dict[str, Any]] = []
value_items: List[Dict[str, Any]] = [] value_items: List[Dict[str, Any]] = []
action_items: List[Dict[str, Any]] = []
is_preferences_view = browse_path == _PREFERENCES_BROWSE_PATH is_preferences_view = browse_path == _PREFERENCES_BROWSE_PATH
parts = _split_config_path(browse_path)
is_multi_instance_root = _is_multi_instance_plugin_root_path(browse_path)
if is_multi_instance_root:
branch = _normalize_multi_instance_branch(parts[1], branch)
for key, value in _visible_config_entries(branch): for key, value in _visible_config_entries(branch):
full_key = key if is_preferences_view else f"{browse_path}.{key}" full_key = key if is_preferences_view else f"{browse_path}.{key}"
@@ -467,7 +761,9 @@ def _build_nested_config_items(
section_items.sort(key=lambda item: str(item.get("title") or "").lower()) section_items.sort(key=lambda item: str(item.get("title") or "").lower())
value_items.sort(key=lambda item: str(item.get("name") or "").lower()) value_items.sort(key=lambda item: str(item.get("name") or "").lower())
return section_items + value_items if is_multi_instance_root:
action_items.append(_build_create_instance_item(parts[0], parts[1]))
return section_items + value_items + action_items
def _build_config_items( def _build_config_items(
@@ -493,12 +789,31 @@ def _build_config_header_lines(browse_path: Optional[str]) -> List[str]:
return [ return [
"Use @N on a section to drill in. Use @.. to go back.", "Use @N on a section to drill in. Use @.. to go back.",
] ]
if _is_multi_instance_plugin_root_path(text):
return [
f"Path: {_format_config_path_label(text)}",
"Use @N on an instance to drill in. Use @N | .config <name> on Add Instance to create a new instance, then update its fields in the table that opens. Use @.. to go back.",
]
parts = _split_config_path(text)
if (
len(parts) == 3
and parts[0] in {"plugin", "provider"}
and _is_multi_instance_plugin_name(parts[1])
):
return [
f"Path: {_format_config_path_label(text)}",
"Use @N | .config <value> to update a setting. After creating an instance, set its path, credentials, or other fields here. Use @.. to go back.",
]
return [ return [
f"Path: {_format_config_path_label(text)}", f"Path: {_format_config_path_label(text)}",
"Use @N on a section to drill in. Use @N | .config <value> to update a setting. Use @.. to go back.", "Use @N on a section to drill in. Use @N | .config <value> to update a setting. Use @.. to go back.",
] ]
def _extract_create_instance_target(args: Sequence[str]) -> Optional[str]:
return _extract_arg_value(args, flags={_CREATE_INSTANCE_FLAG, "--create-instance"}, allow_positional=False)
def _extract_browse_arg(args: Sequence[str]) -> Optional[str]: def _extract_browse_arg(args: Sequence[str]) -> Optional[str]:
return _extract_arg_value(args, flags={"-browse", "--browse"}, allow_positional=False) return _extract_arg_value(args, flags={"-browse", "--browse"}, allow_positional=False)
@@ -529,18 +844,49 @@ def _get_selected_config_item() -> Optional[Dict[str, Any]]:
idx = indices[0] idx = indices[0]
if idx < 0 or idx >= len(items): if idx < 0 or idx >= len(items):
return None return None
item = items[idx] return _normalize_config_item(items[idx])
if isinstance(item, dict):
return item
def _normalize_config_item(candidate: Any) -> Optional[Dict[str, Any]]:
if candidate is None:
return None
normalized: Dict[str, Any] = {} normalized: Dict[str, Any] = {}
for key in ("kind", "key", "title", "browse_path", "name", "value", "value_display", "type"): sources: List[Any] = [candidate]
if isinstance(candidate, dict):
extra = candidate.get("extra")
if isinstance(extra, dict):
sources.append(extra)
else:
try: try:
value = getattr(item, key, None) extra = getattr(candidate, "extra", None)
except Exception: except Exception:
value = None extra = None
if value is not None: if isinstance(extra, dict):
normalized[key] = value sources.append(extra)
for source in sources:
if isinstance(source, dict):
getter = source.get
for key in _CONFIG_ITEM_FIELDS:
if key in normalized:
continue
value = getter(key)
if value is not None:
normalized[key] = value
continue
for key in _CONFIG_ITEM_FIELDS:
if key in normalized:
continue
try:
value = getattr(source, key, None)
except Exception:
value = None
if value is not None:
normalized[key] = value
return normalized or None return normalized or None
@@ -573,6 +919,11 @@ def _show_config_table(
idx, idx,
[".config", "-browse", str(item.get("browse_path"))], [".config", "-browse", str(item.get("browse_path"))],
) )
elif item.get("kind") == "create_instance" and item.get("instance_target"):
table.set_row_selection_action(
idx,
[".config", _CREATE_INSTANCE_FLAG, str(item.get("instance_target"))],
)
ctx.set_last_result_table(table, items) ctx.set_last_result_table(table, items)
ctx.set_current_stage_table(table) ctx.set_current_stage_table(table)
@@ -602,6 +953,15 @@ def _resolve_direct_browse_path(
return _PREFERENCES_BROWSE_PATH return _PREFERENCES_BROWSE_PATH
if lowered in {"plugins", "plugin", "providers", "provider", "tools", "tool"}: if lowered in {"plugins", "plugin", "providers", "provider", "tools", "tool"}:
return _PLUGINS_BROWSE_PATH return _PLUGINS_BROWSE_PATH
plugin_branch = _resolve_plugin_branch(config_data, "plugin", lowered)
if plugin_branch is not None:
return f"plugin.{plugin_branch[0]}"
tool_branch = _resolve_plugin_branch(config_data, "tool", lowered)
if tool_branch is not None:
return f"tool.{tool_branch[0]}"
branch = _resolve_config_branch(config_data, text) branch = _resolve_config_branch(config_data, text)
if isinstance(branch, dict): if isinstance(branch, dict):
return text return text
@@ -629,27 +989,63 @@ def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int:
if browse_path: if browse_path:
return _show_config_table(current_config, browse_path=browse_path) return _show_config_table(current_config, browse_path=browse_path)
selection_item = _get_selected_config_item() selection_item = _get_selected_config_item() or _normalize_config_item(piped_result)
create_instance_target = _extract_create_instance_target(args)
if create_instance_target:
print(
f"Use @N | .config <instance_name> to create a new instance under '{_format_config_path_label(create_instance_target)}', then set its fields in the table that opens."
)
return 0
value_from_pipe = _extract_piped_value(piped_result) value_from_pipe = _extract_piped_value(piped_result)
selection_kind = str((selection_item or {}).get("kind") or "").strip().lower() selection_kind = str((selection_item or {}).get("kind") or "").strip().lower()
selection_key = str((selection_item or {}).get("key") or "").strip() or None selection_key = str((selection_item or {}).get("key") or "").strip() or None
selection_browse_path = str((selection_item or {}).get("browse_path") or "").strip() or None selection_browse_path = str((selection_item or {}).get("browse_path") or "").strip() or None
selection_display_path = str((selection_item or {}).get("display_path") or selection_key or "").strip() or selection_key selection_display_path = str((selection_item or {}).get("display_path") or selection_key or "").strip() or selection_key
selection_instance_target = str((selection_item or {}).get("instance_target") or "").strip() or None
if selection_kind == "section" and selection_browse_path and not args and value_from_pipe is None: if selection_kind == "section" and selection_browse_path and not args and value_from_pipe is None:
return _show_config_table(current_config, browse_path=selection_browse_path) return _show_config_table(current_config, browse_path=selection_browse_path)
if selection_kind == "create_instance" and selection_instance_target:
new_instance_name = value_from_pipe or _extract_selected_update_value(args)
if new_instance_name is None:
print(
f"Use @N | .config <instance_name> to create a new instance under '{_format_config_path_label(selection_instance_target)}', then set its fields in the table that opens."
)
return 0
new_instance_name = _strip_value_quotes(new_instance_name)
try:
new_browse_path, created = _create_or_get_plugin_instance(
current_config,
selection_instance_target,
new_instance_name,
)
_save_updated_config(current_config, new_browse_path)
status_text = "Created" if created else "Using existing"
print(
f"{status_text} instance '{new_instance_name}' at '{_format_config_path_label(new_browse_path)}'. "
"Configure its fields in the table below."
)
return _show_config_table(current_config, browse_path=new_browse_path)
except Exception as exc:
log(f"Error creating config instance '{selection_instance_target}': {exc}")
print(f"Error creating config instance: {exc}")
return 1
if selection_kind == "value" and selection_key: if selection_kind == "value" and selection_key:
new_value = value_from_pipe or _extract_selected_update_value(args) new_value = value_from_pipe or _extract_selected_update_value(args)
if new_value is not None: if new_value is not None:
new_value = _strip_value_quotes(new_value) new_value = _strip_value_quotes(new_value)
target_key = _resolve_update_key(current_config, selection_key)
try: try:
set_nested_config(current_config, selection_key, new_value) set_nested_config(current_config, target_key, new_value)
_save_updated_config(current_config, selection_key) _save_updated_config(current_config, target_key)
print(f"Updated '{selection_display_path}' to '{new_value}'") print(f"Updated '{selection_display_path}' to '{new_value}'")
return 0 return 0
except Exception as exc: except Exception as exc:
log(f"Error updating config '{selection_key}': {exc}") log(f"Error updating config '{target_key}': {exc}")
print(f"Error updating config: {exc}") print(f"Error updating config: {exc}")
return 1 return 1
+3 -3
View File
@@ -2,8 +2,8 @@
## Unreleased (2026-01-05) ## Unreleased (2026-01-05)
- **docs:** Add `docs/provider_authoring.md` as a plugin authoring Quick Start for the strict `ResultTable` API (ResultModel/ColumnSpec/selection_fn). The file keeps its legacy name for compatibility. - **docs:** Add `docs/plugin_authoring.md` as a plugin authoring Quick Start for the strict `ResultTable` API (ResultModel/ColumnSpec/selection_fn).
- **docs:** Add a link from `docs/result_table.md` to the plugin authoring guide. - **docs:** Add a link from `docs/result_table.md` to the plugin authoring guide.
- **tests:** Add `tests/test_provider_author_examples.py` validating example plugin registration and adapter behavior. The test file keeps its legacy name for compatibility. - **tests:** Add plugin-author example coverage validating example plugin registration and adapter behavior.
- **notes:** Existing example plugins (`plugins/example_provider.py`, `plugins/vimm/__init__.py`) are referenced as canonical patterns. - **notes:** Existing example plugins (`plugins/example_plugin.py`, `plugins/vimm/__init__.py`) are referenced as canonical patterns.
@@ -1,9 +1,9 @@
PR Title: docs: Add plugin authoring doc, examples, and tests PR Title: docs: Add plugin authoring doc, examples, and tests
Summary: Summary:
- Add `docs/provider_authoring.md` describing the strict `ResultModel`-based plugin adapter pattern, `ColumnSpec` usage, `selection_fn`, and `TableProviderMixin` for HTML table scraping. - Add `docs/plugin_authoring.md` describing the strict `ResultModel`-based plugin adapter pattern, `ColumnSpec` usage, `selection_fn`, and `TablePluginMixin` for HTML table scraping.
- Link the new doc from `docs/result_table.md`. - Link the new doc from `docs/result_table.md`.
- Add `tests/test_provider_author_examples.py` to validate the example plugin integration with the registry. - Add plugin-author example coverage to validate the example plugin integration with the registry.
Why: Why:
- Provide a short, focused Quick Start to help contributors author plugins that integrate with the strict ResultTable API. - Provide a short, focused Quick Start to help contributors author plugins that integrate with the strict ResultTable API.
@@ -5,16 +5,15 @@ ResultTable API: adapters yield `ResultModel` instances, and plugins register
via `SYS.result_table_adapters.register_plugin` with columns and a via `SYS.result_table_adapters.register_plugin` with columns and a
`selection_fn`. `selection_fn`.
Note: this file keeps its historical `provider_authoring` name, but the public The public terminology is plugin-first, even though some internal classes and
terminology is plugin-first. Some internal classes and metadata fields still use metadata fields still use `Provider` naming.
`Provider` naming.
--- ---
## Quick summary ## Quick summary
- Plugins register a plugin adapter, a `columns` definition, and a `selection_fn`. - Plugins register a plugin adapter, a `columns` definition, and a `selection_fn`.
- `selection_fn` returns CLI args for a selected row. - `selection_fn` returns CLI args for a selected row.
- For HTML table or list scraping, prefer `TableProviderMixin` from `SYS.provider_helpers`. - For HTML table or list scraping, prefer `TablePluginMixin` from `SYS.plugin_helpers`.
## Runtime dependency policy ## Runtime dependency policy
- Treat required runtime dependencies such as Playwright as mandatory: import them unconditionally and let missing dependencies fail fast. - Treat required runtime dependencies such as Playwright as mandatory: import them unconditionally and let missing dependencies fail fast.
@@ -74,16 +73,16 @@ register_plugin("myplugin", adapter, columns=columns_factory, selection_fn=selec
--- ---
## Table scraping with `TableProviderMixin` ## Table scraping with `TablePluginMixin`
If your plugin scrapes HTML tables or list-like results, use `TableProviderMixin`: If your plugin scrapes HTML tables or list-like results, use `TablePluginMixin`:
```py ```py
from ProviderCore.base import Provider from PluginCore.base import Provider
from SYS.provider_helpers import TableProviderMixin from SYS.plugin_helpers import TablePluginMixin
class MyTablePlugin(TableProviderMixin, Provider): class MyTablePlugin(TablePluginMixin, Provider):
URL = ("https://example.org/search",) URL = ("https://example.org/search",)
def validate(self) -> bool: def validate(self) -> bool:
@@ -94,8 +93,8 @@ class MyTablePlugin(TableProviderMixin, Provider):
return self.search_table_from_url(url, limit=limit) return self.search_table_from_url(url, limit=limit)
``` ```
`TableProviderMixin.search_table_from_url` returns `TablePluginMixin.search_table_from_url` returns
`ProviderCore.base.SearchResult` entries. If you want to integrate the plugin `PluginCore.base.SearchResult` entries. If you want to integrate the plugin
with the strict `ResultTable` registry, add a small adapter that converts with the strict `ResultTable` registry, add a small adapter that converts
`SearchResult` to `ResultModel` and register it using `register_plugin`. `SearchResult` to `ResultModel` and register it using `register_plugin`.
@@ -128,22 +127,22 @@ Example test skeleton:
```py ```py
from SYS.result_table_adapters import get_plugin from SYS.result_table_adapters import get_plugin
from plugins import example_provider from plugins import example_plugin
def test_example_plugin_registration(): def test_example_plugin_registration():
plugin = get_plugin("example") plugin = get_plugin("example")
rows = list(plugin.adapter(example_provider.SAMPLE_ITEMS)) rows = list(plugin.adapter(example_plugin.SAMPLE_ITEMS))
assert rows and rows[0].title assert rows and rows[0].title
cols = plugin.get_columns(rows) cols = plugin.get_columns(rows)
assert any(col.name == "title" for col in cols) assert any(col.name == "title" for col in cols)
table = plugin.build_table(example_provider.SAMPLE_ITEMS) table = plugin.build_table(example_plugin.SAMPLE_ITEMS)
assert table.provider == "example" and table.rows assert table.provider == "example" and table.rows
``` ```
--- ---
## References and examples ## References and examples
- Read `plugins/example_provider.py` for a compact example of a strict adapter and dynamic columns. - Read `plugins/example_plugin.py` for a compact example of a strict adapter and dynamic columns.
- Read `plugins/vimm/__init__.py` for a table-oriented plugin that uses `TableProviderMixin` and converts `SearchResult` to `ResultModel` for registration. - Read `plugins/vimm/__init__.py` for a table-oriented plugin that uses `TablePluginMixin` and converts `SearchResult` to `ResultModel` for registration.
- See `docs/provider_guide.md` for a broader plugin development checklist. - See `docs/plugin_guide.md` for a broader plugin development checklist.
@@ -4,9 +4,8 @@
This guide describes how to write, test, and register a plugin so the This guide describes how to write, test, and register a plugin so the
application can discover and use it as a pluggable component. application can discover and use it as a pluggable component.
Note: this file keeps its historical `provider_guide` name, but the public The public model is plugin-first, even though the internal base class still
model is plugin-first. Some runtime classes still use `Provider` naming uses `Provider` naming.
internally.
Keep plugin code small, focused, and well-tested. Bundled plugins and drop-in Keep plugin code small, focused, and well-tested. Bundled plugins and drop-in
plugins share the same `plugins/` layout. plugins share the same `plugins/` layout.
@@ -15,7 +14,7 @@ plugins share the same `plugins/` layout.
## Anatomy of a plugin ## Anatomy of a plugin
A plugin is a Python class that currently extends the internal base class A plugin is a Python class that currently extends the internal base class
`ProviderCore.base.Provider` and implements a few key methods and attributes. `PluginCore.base.Provider` and implements a few key methods and attributes.
Minimum expectations: Minimum expectations:
- `class MyPlugin(Provider):` subclasses the current internal base plugin class. - `class MyPlugin(Provider):` subclasses the current internal base plugin class.
@@ -31,7 +30,7 @@ Optional but common:
--- ---
## SearchResult ## SearchResult
Use `ProviderCore.base.SearchResult` to describe results returned by `search()`. Use `PluginCore.base.SearchResult` to describe results returned by `search()`.
Important fields: Important fields:
- `table` (str): plugin table name - `table` (str): plugin table name
@@ -55,7 +54,7 @@ Return a list of `SearchResult(...)` objects or simple dicts convertible with `.
Example: Example:
```python ```python
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
class HelloPlugin(Provider): class HelloPlugin(Provider):
@@ -132,7 +131,7 @@ pytest -q
- Bundled plugins live under `plugins/` and are auto-discovered from that package. - Bundled plugins live under `plugins/` and are auto-discovered from that package.
- External plugins can be dropped into `plugins/` or any directory listed in `MM_PLUGIN_PATH` or `MEDEIA_PLUGIN_PATH`. - External plugins can be dropped into `plugins/` or any directory listed in `MM_PLUGIN_PATH` or `MEDEIA_PLUGIN_PATH`.
- Package directories are preferred so plugin-specific files travel with the plugin. - Package directories are preferred so plugin-specific files travel with the plugin.
- Plugin authors should import from `ProviderCore.*`. - Plugin authors should import from `PluginCore.*`.
If a plugin supports multiple configured endpoints or accounts, the user-facing If a plugin supports multiple configured endpoints or accounts, the user-facing
concept is a plugin instance. Some stored config still lives under legacy key concept is a plugin instance. Some stored config still lives under legacy key
+1 -1
View File
@@ -181,4 +181,4 @@ another, such as artist to discography drill-in.
- ResultTable implementation: `SYS/result_table.py` - ResultTable implementation: `SYS/result_table.py`
- Pipeline helpers: `SYS/pipeline.py` - Pipeline helpers: `SYS/pipeline.py`
- Row replay and selection flow: `SYS/pipeline.py` - Row replay and selection flow: `SYS/pipeline.py`
- Plugin authoring guide: `docs/provider_authoring.md` - Plugin authoring guide: `docs/plugin_authoring.md`
+2 -2
View File
@@ -29,13 +29,13 @@ drop-in plugin search paths are:
Plugin rules: Plugin rules:
- A plugin can be a single `.py` file or a package directory with `__init__.py`. - A plugin can be a single `.py` file or a package directory with `__init__.py`.
- Current plugin classes inherit from `ProviderCore.base.Provider`. - Current plugin classes inherit from `PluginCore.base.Provider`.
- Give the plugin a stable name using `PLUGIN_NAME` or the class name. - Give the plugin a stable name using `PLUGIN_NAME` or the class name.
Example skeleton: Example skeleton:
```python ```python
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
class MyPlugin(Provider): class MyPlugin(Provider):
+3 -3
View File
@@ -13,8 +13,8 @@ from urllib.parse import urlparse
from API.HTTP import HTTPClient, _download_direct_file from API.HTTP import HTTPClient, _download_direct_file
from plugins.alldebrid.api import AllDebridClient, parse_magnet_or_hash, is_torrent_file from plugins.alldebrid.api import AllDebridClient, parse_magnet_or_hash, is_torrent_file
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.provider_helpers import TableProviderMixin from SYS.plugin_helpers import TablePluginMixin
from SYS.item_accessors import get_field as _extract_value from SYS.item_accessors import get_field as _extract_value
from SYS.utils import sanitize_filename from SYS.utils import sanitize_filename
from SYS.logger import log, debug, debug_panel from SYS.logger import log, debug, debug_panel
@@ -637,7 +637,7 @@ def adjust_output_dir_for_alldebrid(
return output_dir return output_dir
class AllDebrid(TableProviderMixin, Provider): class AllDebrid(TablePluginMixin, Provider):
"""AllDebrid account provider with magnet folder/file browsing and downloads. """AllDebrid account provider with magnet folder/file browsing and downloads.
This provider uses the new table system (strict ResultTable adapter pattern) for This provider uses the new table system (strict ResultTable adapter pattern) for
+1 -1
View File
@@ -4,7 +4,7 @@ import sys
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.logger import log, debug, debug_panel from SYS.logger import log, debug, debug_panel
from tool.playwright import PlaywrightTool from tool.playwright import PlaywrightTool
@@ -1,11 +1,11 @@
"""Example plugin that uses the new `ResultTable` API. """Example plugin that uses the new `ResultTable` API.
This module demonstrates a minimal provider adapter that yields `ResultModel` This module demonstrates a minimal plugin adapter that yields `ResultModel`
instances, a set of `ColumnSpec` definitions, and a tiny CLI-friendly renderer instances, a set of `ColumnSpec` definitions, and a tiny CLI-friendly renderer
(`render_table`) for demonstration. (`render_table`) for demonstration.
Run this to see sample output: Run this to see sample output:
python -m Provider.example_provider python -m plugins.example_plugin
Example usage (piped selector): Example usage (piped selector):
plugin-table -plugin example -sample | select -select 1 | add-file -instance default plugin-table -plugin example -sample | select -select 1 | add-file -instance default
@@ -44,7 +44,7 @@ SAMPLE_ITEMS = [
def adapter(items: Iterable[Dict[str, Any]]) -> Iterable[ResultModel]: def adapter(items: Iterable[Dict[str, Any]]) -> Iterable[ResultModel]:
"""Convert provider-specific items into `ResultModel` instances. """Convert plugin-specific items into `ResultModel` instances.
This adapter enforces the strict API requirement: it yields only This adapter enforces the strict API requirement: it yields only
`ResultModel` instances (no legacy dict objects). `ResultModel` instances (no legacy dict objects).
@@ -62,7 +62,7 @@ def adapter(items: Iterable[Dict[str, Any]]) -> Iterable[ResultModel]:
# Columns are intentionally *not* mandated. Create a factory that inspects # Columns are intentionally *not* mandated. Create a factory that inspects
# sample rows and builds only columns that make sense for the provider data. # sample rows and builds only columns that make sense for the plugin data.
from SYS.result_table_api import metadata_column from SYS.result_table_api import metadata_column
@@ -112,7 +112,7 @@ register_plugin(
adapter, adapter,
columns=columns_factory, columns=columns_factory,
selection_fn=selection_fn, selection_fn=selection_fn,
metadata={"description": "Example provider demonstrating dynamic columns and selectors"}, metadata={"description": "Example plugin demonstrating dynamic columns and selectors"},
) )
+1 -1
View File
@@ -4,7 +4,7 @@ import os
import sys import sys
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ProviderCore.base import Provider from PluginCore.base import Provider
from SYS.logger import log from SYS.logger import log
+1 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote, unquote, urlparse from urllib.parse import quote, unquote, urlparse
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments
def _coerce_bool(value: Any, default: bool = False) -> bool: def _coerce_bool(value: Any, default: bool = False) -> bool:
+2 -2
View File
@@ -5,7 +5,7 @@ This minimal plugin demonstrates the typical hooks a plugin may implement:
- `search()` to return `SearchResult` items - `search()` to return `SearchResult` items
- `download()` to persist a sample file (useful for local tests) - `download()` to persist a sample file (useful for local tests)
See `docs/provider_guide.md` for authoring guidance. See `docs/plugin_guide.md` for authoring guidance.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,7 +13,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
class HelloProvider(Provider): class HelloProvider(Provider):
+1 -1
View File
@@ -16,7 +16,7 @@ from plugins.tidal.api import (
extract_artists, extract_artists,
stringify, stringify,
) )
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments
from SYS.field_access import get_field from SYS.field_access import get_field
from plugins.tidal_manifest import resolve_tidal_manifest_path from plugins.tidal_manifest import resolve_tidal_manifest_path
from SYS import pipeline as pipeline_context from SYS import pipeline as pipeline_context
+1 -1
View File
@@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional
from urllib.parse import quote, unquote, urlparse from urllib.parse import quote, unquote, urlparse
from API.HTTP import _download_direct_file from API.HTTP import _download_direct_file
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.utils import sanitize_filename, unique_path from SYS.utils import sanitize_filename, unique_path
from SYS.logger import log from SYS.logger import log
from SYS.config import get_provider_block from SYS.config import get_provider_block
+4 -4
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import urljoin, urlparse, unquote from urllib.parse import urljoin, urlparse, unquote
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.utils import sanitize_filename from SYS.utils import sanitize_filename
from SYS.logger import log, debug from SYS.logger import log, debug
from SYS.models import ProgressBar from SYS.models import ProgressBar
@@ -383,7 +383,7 @@ def _enrich_book_tags_from_isbn(isbn: str,
# 2) isbnsearch metadata plugin fallback. # 2) isbnsearch metadata plugin fallback.
try: try:
from plugins.metadata_provider import get_metadata_plugin from plugins.metadata_plugin import get_metadata_plugin
provider = get_metadata_plugin("isbnsearch", provider = get_metadata_plugin("isbnsearch",
config or {}) config or {})
@@ -660,7 +660,7 @@ class Libgen(Provider):
"libgen": ["download-file"], "libgen": ["download-file"],
} }
# Domains that should be routed to this provider when the user supplies a URL. # Domains that should be routed to this provider when the user supplies a URL.
# (Used by ProviderCore.registry.match_provider_name_for_url) # (Used by PluginCore.registry.match_provider_name_for_url)
URL_DOMAINS = ( URL_DOMAINS = (
"libgen.gl", "libgen.gl",
"libgen.li", "libgen.li",
@@ -1056,7 +1056,7 @@ class Libgen(Provider):
def download_url(self, url: str, output_dir: Path) -> Optional[Path]: def download_url(self, url: str, output_dir: Path) -> Optional[Path]:
"""Download a direct LibGen URL using the regular mirror logic.""" """Download a direct LibGen URL using the regular mirror logic."""
try: try:
from ProviderCore.base import SearchResult from PluginCore.base import SearchResult
sr = SearchResult( sr = SearchResult(
table="libgen", table="libgen",
title="libgen", title="libgen",
+1 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from plugins.loc.api import LOCClient from plugins.loc.api import LOCClient
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.cli_syntax import get_free_text, parse_query from SYS.cli_syntax import get_free_text, parse_query
from SYS.logger import log from SYS.logger import log
+1 -1
View File
@@ -4,7 +4,7 @@ import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from ProviderCore.base import Provider from PluginCore.base import Provider
from SYS.metadata import write_metadata, write_tags from SYS.metadata import write_metadata, write_tags
from SYS.utils import sanitize_filename, sha256_file, unique_path from SYS.utils import sanitize_filename, sha256_file, unique_path
+3 -3
View File
@@ -11,8 +11,8 @@ from urllib.parse import quote
from API.requests_client import get_requests_session from API.requests_client import get_requests_session
from SYS.utils import ffprobe as probe_media_metadata from SYS.utils import ffprobe as probe_media_metadata
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.provider_helpers import TableProviderMixin from SYS.plugin_helpers import TablePluginMixin
from SYS.logger import log from SYS.logger import log
_MATRIX_INIT_CHECK_CACHE: Dict[str, _MATRIX_INIT_CHECK_CACHE: Dict[str,
@@ -276,7 +276,7 @@ def _matrix_health_check(*,
return False, str(exc) return False, str(exc)
class Matrix(TableProviderMixin, Provider): class Matrix(TablePluginMixin, Provider):
"""Matrix (Element) room provider with file uploads and selection. """Matrix (Element) room provider with file uploads and selection.
This provider uses the new table system (strict ResultTable adapter pattern) for This provider uses the new table system (strict ResultTable adapter pattern) for
+1 -1
View File
@@ -22,7 +22,7 @@ from SYS.command_parsing import (
normalize_to_list as _normalize_to_list, normalize_to_list as _normalize_to_list,
) )
from SYS import pipeline as ctx from SYS import pipeline as ctx
from ProviderCore.registry import get_plugin, get_plugin_for_url from PluginCore.registry import get_plugin, get_plugin_for_url
_MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items" _MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items"
_MATRIX_PENDING_TEXT_KEY = "matrix_pending_text" _MATRIX_PENDING_TEXT_KEY = "matrix_pending_text"
@@ -10,7 +10,7 @@ import subprocess
from API.HTTP import HTTPClient from API.HTTP import HTTPClient
from API.requests_client import get_requests_session from API.requests_client import get_requests_session
from ProviderCore.base import SearchResult from PluginCore.base import SearchResult
try: try:
from plugins.tidal import Tidal from plugins.tidal import Tidal
except ImportError: # pragma: no cover - optional except ImportError: # pragma: no cover - optional
+1 -1
View File
@@ -10,7 +10,7 @@ from datetime import datetime, timedelta
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
from pathlib import Path from pathlib import Path
from SYS.cmdlet_spec import Cmdlet, CmdletArg, parse_cmdlet_args from SYS.cmdlet_spec import Cmdlet, CmdletArg, parse_cmdlet_args
from ProviderCore.registry import get_plugin, get_plugin_for_url, list_plugin_names_with_capability from PluginCore.registry import get_plugin, get_plugin_for_url, list_plugin_names_with_capability
from SYS.logger import debug, get_thread_stream, is_debug_enabled, set_debug, set_thread_stream from SYS.logger import debug, get_thread_stream, is_debug_enabled, set_debug, set_thread_stream
from SYS.result_table import Table from SYS.result_table import Table
from plugins.mpv.mpv_ipc import MPV from plugins.mpv.mpv_ipc import MPV
+1 -1
View File
@@ -1170,7 +1170,7 @@ def _infer_hydrus_store_from_url_target(*, target: str, config: dict) -> Optiona
is_hydrus_backend = backend_type == "hydrusnetwork" or backend_class == "hydrusnetwork" is_hydrus_backend = backend_type == "hydrusnetwork" or backend_class == "hydrusnetwork"
if not is_hydrus_backend: if not is_hydrus_backend:
try: try:
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
hydrus_provider = get_plugin("hydrusnetwork", config) hydrus_provider = get_plugin("hydrusnetwork", config)
checker = getattr(hydrus_provider, "is_backend", None) if hydrus_provider is not None else None checker = getattr(hydrus_provider, "is_backend", None) if hydrus_provider is not None else None
+1 -1
View File
@@ -70,7 +70,7 @@ from SYS.config import load_config, reload_config # noqa: E402
from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402 from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402
from SYS.repl_queue import enqueue_repl_command # noqa: E402 from SYS.repl_queue import enqueue_repl_command # noqa: E402
from SYS.utils import format_bytes # noqa: E402 from SYS.utils import format_bytes # noqa: E402
from ProviderCore.registry import get_plugin, get_plugin_class # noqa: E402 from PluginCore.registry import get_plugin, get_plugin_class # noqa: E402
from tool.ytdlp import get_display_format_id, get_selection_format_id # noqa: E402 from tool.ytdlp import get_display_format_id, get_selection_format_id # noqa: E402
REQUEST_PROP = "user-data/medeia-pipeline-request" REQUEST_PROP = "user-data/medeia-pipeline-request"
+3 -3
View File
@@ -18,11 +18,11 @@ import requests
from API.HTTP import HTTPClient from API.HTTP import HTTPClient
from API.requests_client import get_requests_session from API.requests_client import get_requests_session
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.utils import sanitize_filename from SYS.utils import sanitize_filename
from SYS.cli_syntax import get_field, get_free_text, parse_query from SYS.cli_syntax import get_field, get_free_text, parse_query
from SYS.logger import log from SYS.logger import log
from plugins.metadata_provider import ( from plugins.metadata_plugin import (
archive_item_metadata_to_tags, archive_item_metadata_to_tags,
fetch_archive_item_metadata, fetch_archive_item_metadata,
) )
@@ -654,7 +654,7 @@ class OpenLibrary(Provider):
] ]
# Domains that should be routed to this provider when the user supplies a URL. # Domains that should be routed to this provider when the user supplies a URL.
# (Used by ProviderCore.registry.match_provider_name_for_url) # (Used by PluginCore.registry.match_provider_name_for_url)
URL_DOMAINS = ( URL_DOMAINS = (
"openlibrary.org", "openlibrary.org",
"archive.org", "archive.org",
+1 -1
View File
@@ -5,7 +5,7 @@ import hashlib
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.logger import log from SYS.logger import log
from SYS.utils import format_bytes from SYS.utils import format_bytes
+1 -1
View File
@@ -13,7 +13,7 @@ from urllib.parse import quote, unquote, urlparse
import paramiko import paramiko
from scp import SCPClient from scp import SCPClient
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments
def _coerce_bool(value: Any, default: bool = False) -> bool: def _coerce_bool(value: Any, default: bool = False) -> bool:
+1 -1
View File
@@ -11,7 +11,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.logger import log, debug, debug_panel from SYS.logger import log, debug, debug_panel
from SYS.models import ProgressBar from SYS.models import ProgressBar
+1 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple from typing import Any, Dict, List, Optional, Sequence, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.logger import debug from SYS.logger import debug
_TELEGRAM_DEFAULT_TIMESTAMP_STEM_RE = re.compile( _TELEGRAM_DEFAULT_TIMESTAMP_STEM_RE = re.compile(
+1 -1
View File
@@ -9,7 +9,7 @@ from SYS.command_parsing import has_flag as _has_flag, normalize_to_list as _nor
from SYS.logger import log from SYS.logger import log
from SYS.result_table import Table from SYS.result_table import Table
from SYS import pipeline as ctx from SYS import pipeline as ctx
from ProviderCore.registry import get_plugin from PluginCore.registry import get_plugin
_TELEGRAM_PENDING_ITEMS_KEY = "telegram_pending_items" _TELEGRAM_PENDING_ITEMS_KEY = "telegram_pending_items"
+2 -2
View File
@@ -16,7 +16,7 @@ from plugins.tidal.api import (
extract_artists, extract_artists,
stringify, stringify,
) )
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.field_access import get_field from SYS.field_access import get_field
from plugins.tidal_manifest import resolve_tidal_manifest_path from plugins.tidal_manifest import resolve_tidal_manifest_path
from SYS import pipeline as pipeline_context from SYS import pipeline as pipeline_context
@@ -216,7 +216,7 @@ class Tidal(Provider):
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]: def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
"""Parse inline `key:value` query arguments. """Parse inline `key:value` query arguments.
Unlike the generic parser in ProviderCore, this supports multi-word Unlike the generic parser in PluginCore, this supports multi-word
values (e.g. `artist:elliott smith`). values (e.g. `artist:elliott smith`).
Returns: Returns:
+1 -1
View File
@@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
import requests import requests
from API.requests_client import get_requests_session from API.requests_client import get_requests_session
from ProviderCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.logger import debug, log from SYS.logger import debug, log
try: # Preferred HTML parser try: # Preferred HTML parser
from lxml import html as lxml_html from lxml import html as lxml_html
+5 -5
View File
@@ -16,14 +16,14 @@ import re
from pathlib import Path from pathlib import Path
from API.HTTP import HTTPClient from API.HTTP import HTTPClient
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments
from ProviderCore.inline_utils import resolve_filter from PluginCore.inline_utils import resolve_filter
from SYS.logger import debug, debug_panel from SYS.logger import debug, debug_panel
from SYS.provider_helpers import TableProviderMixin from SYS.plugin_helpers import TablePluginMixin
from tool.playwright import PlaywrightTool from tool.playwright import PlaywrightTool
class Vimm(TableProviderMixin, Provider): class Vimm(TablePluginMixin, Provider):
"""Minimal provider for vimm.net vault listings using TableProvider mixin. """Minimal provider for vimm.net vault listings using TableProvider mixin.
NOTES / HOW-TO (selection & auto-download): NOTES / HOW-TO (selection & auto-download):
@@ -142,7 +142,7 @@ class Vimm(TableProviderMixin, Provider):
], ],
"region": REGION_CHOICES, "region": REGION_CHOICES,
} }
# ProviderCore still looks for INLINE_QUERY_FIELD_CHOICES, so expose this # PluginCore still looks for INLINE_QUERY_FIELD_CHOICES, so expose this
# mapping once and keep QUERY_ARG_CHOICES as the readable name we prefer. # mapping once and keep QUERY_ARG_CHOICES as the readable name we prefer.
INLINE_QUERY_FIELD_CHOICES = QUERY_ARG_CHOICES INLINE_QUERY_FIELD_CHOICES = QUERY_ARG_CHOICES
+3 -3
View File
@@ -13,8 +13,8 @@ from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments
from SYS.provider_helpers import TableProviderMixin from SYS.plugin_helpers import TablePluginMixin
from SYS.logger import debug, log from SYS.logger import debug, log
from SYS.models import DownloadError, DownloadMediaResult, DownloadOptions from SYS.models import DownloadError, DownloadMediaResult, DownloadOptions
from SYS.payload_builders import build_file_result_payload, build_table_result_payload from SYS.payload_builders import build_file_result_payload, build_table_result_payload
@@ -501,7 +501,7 @@ def _build_pipe_objects(
return pipe_objects return pipe_objects
class ytdlp(TableProviderMixin, Provider): class ytdlp(TablePluginMixin, Provider):
"""yt-dlp-backed search and direct download plugin.""" """yt-dlp-backed search and direct download plugin."""
PLUGIN_NAME = "ytdlp" PLUGIN_NAME = "ytdlp"
+1 -1
View File
@@ -4,7 +4,7 @@ import os
import sys import sys
from typing import Any from typing import Any
from ProviderCore.base import Provider from PluginCore.base import Provider
from SYS.logger import log from SYS.logger import log
+3 -3
View File
@@ -35,7 +35,7 @@ Plugins are the main integration surface for the app.
- Plugins can expose named instances, so one plugin can target multiple endpoints, accounts, or servers via `-instance <name>`. - Plugins can expose named instances, so one plugin can target multiple endpoints, accounts, or servers via `-instance <name>`.
- Bundled and external plugins use the same `plugins/<name>/` layout. - Bundled and external plugins use the same `plugins/<name>/` layout.
- External plugin search paths include the repo `plugins/` folder, the current working directory `plugins/` folder, `MM_PLUGIN_PATH`, and `MEDEIA_PLUGIN_PATH`. - External plugin search paths include the repo `plugins/` folder, the current working directory `plugins/` folder, `MM_PLUGIN_PATH`, and `MEDEIA_PLUGIN_PATH`.
- Plugin authoring still uses the current Python base class name `ProviderCore.base.Provider`. That is an implementation detail rather than the preferred user-facing term. - Plugin authoring still uses the current Python base class name `PluginCore.base.Provider`. That is an implementation detail rather than the preferred user-facing term.
See [plugins/README.md](plugins/README.md) for plugin packaging and discovery details. See [plugins/README.md](plugins/README.md) for plugin packaging and discovery details.
@@ -140,8 +140,8 @@ The exact meaning of `@1` depends on the current table and plugin. For example,
- [docs/tag_template_syntax.md](docs/tag_template_syntax.md) - [docs/tag_template_syntax.md](docs/tag_template_syntax.md)
- [plugins/README.md](plugins/README.md) - [plugins/README.md](plugins/README.md)
- [docs/provider_guide.md](docs/provider_guide.md) - [docs/plugin_guide.md](docs/plugin_guide.md)
- [docs/provider_authoring.md](docs/provider_authoring.md) - [docs/plugin_authoring.md](docs/plugin_authoring.md)
- [docs/ftp_plugin_tutorial.md](docs/ftp_plugin_tutorial.md) - [docs/ftp_plugin_tutorial.md](docs/ftp_plugin_tutorial.md)
- [docs/scp_plugin_tutorial.md](docs/scp_plugin_tutorial.md) - [docs/scp_plugin_tutorial.md](docs/scp_plugin_tutorial.md)
- [docs/BOOTSTRAP_TROUBLESHOOTING.md](docs/BOOTSTRAP_TROUBLESHOOTING.md) - [docs/BOOTSTRAP_TROUBLESHOOTING.md](docs/BOOTSTRAP_TROUBLESHOOTING.md)
+1 -2
View File
@@ -121,8 +121,7 @@ packages = [
"cmdnat", "cmdnat",
"API", "API",
"Store", "Store",
"ProviderCore", "PluginCore",
"Provider",
"SYS", "SYS",
"tool", "tool",
"TUI", "TUI",