From e8913d13444e0321fb181929d34d41c51d88231b Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 21 May 2026 16:19:17 -0700 Subject: [PATCH] cleanup and rename provider to plugin --- CLI.py | 8 +- {ProviderCore => PluginCore}/base.py | 0 {ProviderCore => PluginCore}/commands.py | 0 {ProviderCore => PluginCore}/inline_utils.py | 0 {ProviderCore => PluginCore}/registry.py | 2 +- ProviderCore/__init__.py | 5 - SYS/cmdlet_catalog.py | 4 +- SYS/html_table.py | 2 +- SYS/metadata.py | 2 +- SYS/pipeline.py | 8 +- SYS/plugin_config.py | 6 +- ...{provider_helpers.py => plugin_helpers.py} | 22 +- cmdlet/__init__.py | 4 +- cmdlet/_shared.py | 6 +- cmdlet/file/add.py | 6 +- cmdlet/file/archive.py | 2 +- cmdlet/file/delete.py | 2 +- cmdlet/file/download.py | 4 +- cmdlet/file/merge.py | 2 +- cmdlet/file/search.py | 2 +- cmdlet/metadata/get_relationship.py | 2 +- cmdlet/metadata/relationship_add.py | 2 +- cmdlet/metadata/tag_get.py | 16 +- cmdlet/{provider_table.py => plugin_table.py} | 12 +- cmdnat/_status_shared.py | 2 +- cmdnat/config.py | 428 +++++++++++++++++- docs/CHANGELOG.md | 6 +- ...ER_AUTHORING.md => PR_PLUGIN_AUTHORING.md} | 4 +- ...vider_authoring.md => plugin_authoring.md} | 33 +- docs/{provider_guide.md => plugin_guide.md} | 13 +- docs/result_table.md | 2 +- plugins/README.md | 4 +- plugins/alldebrid/__init__.py | 6 +- plugins/bandcamp/__init__.py | 2 +- ...{example_provider.py => example_plugin.py} | 10 +- plugins/fileio/__init__.py | 2 +- plugins/ftp/__init__.py | 2 +- plugins/hello/__init__.py | 4 +- plugins/hifi/__init__.py | 2 +- plugins/internetarchive/__init__.py | 2 +- plugins/libgen/__init__.py | 8 +- plugins/loc/__init__.py | 2 +- plugins/local/__init__.py | 2 +- plugins/matrix/__init__.py | 6 +- plugins/matrix/commands.py | 2 +- ...etadata_provider.py => metadata_plugin.py} | 2 +- plugins/mpv/commands.py | 2 +- plugins/mpv/lyric.py | 2 +- plugins/mpv/pipeline_helper.py | 2 +- plugins/openlibrary/__init__.py | 6 +- plugins/podcastindex/__init__.py | 2 +- plugins/scp/__init__.py | 2 +- plugins/soulseek/__init__.py | 2 +- plugins/telegram/__init__.py | 2 +- plugins/telegram/commands.py | 2 +- plugins/tidal/__init__.py | 4 +- plugins/torrent/__init__.py | 2 +- plugins/vimm/__init__.py | 10 +- plugins/ytdlp/__init__.py | 6 +- plugins/zeroxzero/__init__.py | 2 +- readme.md | 6 +- scripts/pyproject.toml | 3 +- 62 files changed, 553 insertions(+), 165 deletions(-) rename {ProviderCore => PluginCore}/base.py (100%) rename {ProviderCore => PluginCore}/commands.py (100%) rename {ProviderCore => PluginCore}/inline_utils.py (100%) rename {ProviderCore => PluginCore}/registry.py (99%) delete mode 100644 ProviderCore/__init__.py rename SYS/{provider_helpers.py => plugin_helpers.py} (83%) rename cmdlet/{provider_table.py => plugin_table.py} (93%) rename docs/{PR_PROVIDER_AUTHORING.md => PR_PLUGIN_AUTHORING.md} (58%) rename docs/{provider_authoring.md => plugin_authoring.md} (81%) rename docs/{provider_guide.md => plugin_guide.md} (92%) rename plugins/{example_provider.py => example_plugin.py} (95%) rename plugins/{metadata_provider.py => metadata_plugin.py} (99%) diff --git a/CLI.py b/CLI.py index fa00da6..76fd59b 100644 --- a/CLI.py +++ b/CLI.py @@ -98,7 +98,7 @@ from SYS.result_table import Table from SYS.worker import WorkerManagerRegistry, WorkerStages, WorkerOutputMirror, WorkerStageSession 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": canonical_cmd = (cmd_name or "").replace("_", "-").lower() try: - from ProviderCore.registry import ( + from PluginCore.registry import ( list_configured_plugin_names_with_capability, list_plugin_names_for_cmdlet, ) @@ -546,7 +546,7 @@ class CmdletIntrospection: if normalized_arg == "scrape": try: - from plugins.metadata_provider import list_metadata_plugins + from plugins.metadata_plugin import list_metadata_plugins metadata_plugins = list_metadata_plugins(config) or {} if metadata_plugins: @@ -765,7 +765,7 @@ class CmdletCompleter(Completer): return [] try: - from ProviderCore.registry import get_plugin_class + from PluginCore.registry import get_plugin_class except Exception: return [] diff --git a/ProviderCore/base.py b/PluginCore/base.py similarity index 100% rename from ProviderCore/base.py rename to PluginCore/base.py diff --git a/ProviderCore/commands.py b/PluginCore/commands.py similarity index 100% rename from ProviderCore/commands.py rename to PluginCore/commands.py diff --git a/ProviderCore/inline_utils.py b/PluginCore/inline_utils.py similarity index 100% rename from ProviderCore/inline_utils.py rename to PluginCore/inline_utils.py diff --git a/ProviderCore/registry.py b/PluginCore/registry.py similarity index 99% rename from ProviderCore/registry.py rename to PluginCore/registry.py index bf9c259..f434a10 100644 --- a/ProviderCore/registry.py +++ b/PluginCore/registry.py @@ -22,7 +22,7 @@ from urllib.parse import urlparse 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") diff --git a/ProviderCore/__init__.py b/ProviderCore/__init__.py deleted file mode 100644 index a72cbea..0000000 --- a/ProviderCore/__init__.py +++ /dev/null @@ -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. -""" diff --git a/SYS/cmdlet_catalog.py b/SYS/cmdlet_catalog.py index 65a16bf..7ed2425 100644 --- a/SYS/cmdlet_catalog.py +++ b/SYS/cmdlet_catalog.py @@ -6,8 +6,8 @@ from pathlib import Path from types import ModuleType from typing import Any, Dict, List, Optional import logging -from ProviderCore.commands import get_primary_command_object -from ProviderCore.registry import get_plugin +from PluginCore.commands import get_primary_command_object +from PluginCore.registry import get_plugin logger = logging.getLogger(__name__) try: diff --git a/SYS/html_table.py b/SYS/html_table.py index 1ca9473..37daf5d 100644 --- a/SYS/html_table.py +++ b/SYS/html_table.py @@ -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 # 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]: diff --git a/SYS/metadata.py b/SYS/metadata.py index 9a79eea..946ff84 100644 --- a/SYS/metadata.py +++ b/SYS/metadata.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) from pathlib import Path 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 try: # Optional; used when available for richer metadata fetches diff --git a/SYS/pipeline.py b/SYS/pipeline.py index cf8bb30..269dc94 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -1562,7 +1562,7 @@ class PipelineExecutor: _add(getattr(item, "table", None)) try: - from ProviderCore.registry import get_plugin, is_known_plugin_name + from PluginCore.registry import get_plugin, is_known_plugin_name except Exception: get_plugin = None # type: ignore is_known_plugin_name = None # type: ignore @@ -1679,7 +1679,7 @@ class PipelineExecutor: _add(getattr(item, "source", None)) try: - from ProviderCore.registry import get_plugin, is_known_plugin_name + from PluginCore.registry import get_plugin, is_known_plugin_name except Exception: return None @@ -2313,7 +2313,7 @@ class PipelineExecutor: auto_stage = None if isinstance(table_type, str) and table_type: 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) except Exception: @@ -3098,7 +3098,7 @@ class PipelineExecutor: auto_stage = None if isinstance(table_type, str) and table_type: 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 # to the auto stage when we are appending a new last stage. diff --git a/SYS/plugin_config.py b/SYS/plugin_config.py index 6679f13..84652ce 100644 --- a/SYS/plugin_config.py +++ b/SYS/plugin_config.py @@ -6,7 +6,7 @@ import pkgutil from typing import Any, Dict, Iterable, List, Optional 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__) @@ -194,7 +194,7 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]: def get_configurable_store_types() -> List[str]: """Return configurable multi-instance plugin types (formerly 'store types').""" - from ProviderCore.registry import REGISTRY + from PluginCore.registry import REGISTRY options: List[str] = [] for info in REGISTRY.iter_plugins(): plugin_cls = info.plugin_class @@ -205,7 +205,7 @@ def get_configurable_store_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.""" - from ProviderCore.registry import REGISTRY + from PluginCore.registry import REGISTRY options: List[str] = [] for info in REGISTRY.iter_plugins(): plugin_cls = info.plugin_class diff --git a/SYS/provider_helpers.py b/SYS/plugin_helpers.py similarity index 83% rename from SYS/provider_helpers.py rename to SYS/plugin_helpers.py index ec82f87..a6303f9 100644 --- a/SYS/provider_helpers.py +++ b/SYS/plugin_helpers.py @@ -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 -`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",) def search(self, query, limit=50, **kwargs): @@ -21,7 +21,7 @@ from __future__ import annotations from typing import List, Optional from API.HTTP import HTTPClient -from ProviderCore.base import SearchResult +from PluginCore.base import SearchResult from SYS.html_table import extract_records import lxml.html as lxml_html @@ -29,8 +29,8 @@ import logging logger = logging.getLogger(__name__) -class TableProviderMixin: - """Mixin to simplify providers that scrape table/list results from HTML. +class TablePluginMixin: + """Mixin to simplify plugins that scrape table/list results from HTML. Methods: - search_table_from_url(url, limit, xpaths): fetches HTML, extracts records, returns SearchResults @@ -59,7 +59,7 @@ class TableProviderMixin: resp = client.get(url) content = resp.content except Exception: - logger.exception("Failed to fetch URL %s for provider %s", url, getattr(self, 'name', '')) + logger.exception("Failed to fetch URL %s for plugin %s", url, getattr(self, 'name', '')) return [] # Ensure we pass an lxml document or string (httpx returns bytes) @@ -99,14 +99,14 @@ class TableProviderMixin: results.append( SearchResult( - table=(getattr(self, "name", "provider") or "provider"), + table=(getattr(self, "name", "plugin") or "plugin"), title=title, path=path, detail="", annotations=[], media_kind="file", size_bytes=None, - tag={getattr(self, "name", "provider")}, + tag={getattr(self, "name", "plugin") or "plugin"}, columns=cols, full_metadata={"raw_record": rec}, ) diff --git a/cmdlet/__init__.py b/cmdlet/__init__.py index af80931..f81addc 100644 --- a/cmdlet/__init__.py +++ b/cmdlet/__init__.py @@ -88,7 +88,7 @@ def _load_root_modules() -> None: def _load_helper_modules() -> None: # 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 # registered under their legacy command names. @@ -118,7 +118,7 @@ def _register_native_commands() -> None: def _register_plugin_commands() -> None: try: - from ProviderCore.commands import register_plugin_commands + from PluginCore.commands import register_plugin_commands except Exception: return try: diff --git a/cmdlet/_shared.py b/cmdlet/_shared.py index 58ce704..9586bd5 100644 --- a/cmdlet/_shared.py +++ b/cmdlet/_shared.py @@ -276,7 +276,7 @@ class SharedArgs: # Plugin-based multi-instance backends (config["plugin"] / config["provider"] sections) try: - from ProviderCore.registry import REGISTRY + from PluginCore.registry import REGISTRY plugin_instances = REGISTRY.list_storage_plugin_instances(config) for _plugin_name, instance_names in plugin_instances.items(): names.update(instance_names) @@ -1448,7 +1448,7 @@ def fetch_hydrus_metadata( client = hydrus_client hydrus_provider = None try: - from ProviderCore.registry import get_plugin + from PluginCore.registry import get_plugin hydrus_provider = get_plugin("hydrusnetwork", config) except Exception: @@ -4177,7 +4177,7 @@ def check_url_exists_in_storage( hydrus_provider = None try: - from ProviderCore.registry import get_plugin + from PluginCore.registry import get_plugin hydrus_provider = get_plugin("hydrusnetwork", config) except Exception: diff --git a/cmdlet/file/add.py b/cmdlet/file/add.py index 06649a4..108923f 100644 --- a/cmdlet/file/add.py +++ b/cmdlet/file/add.py @@ -63,7 +63,7 @@ class _CommandDependencies: def get_plugin(self, name: str) -> Optional[Any]: """Cached plugin lookup by name.""" - from ProviderCore.registry import get_plugin + from PluginCore.registry import get_plugin norm_name = str(name or "").strip().lower() if not norm_name: @@ -77,7 +77,7 @@ class _CommandDependencies: def get_plugin_with_capability(self, name: str, capability: str) -> Optional[Any]: """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() if not norm_name: @@ -2336,7 +2336,7 @@ class Add_File(Cmdlet): delete_after: bool, ) -> int: """Handle uploading via an upload plugin (e.g. 0x0).""" - from ProviderCore.registry import ( + from PluginCore.registry import ( get_plugin_with_capability, list_plugin_names_with_capability, list_plugins_with_capability, diff --git a/cmdlet/file/archive.py b/cmdlet/file/archive.py index a3a31ed..49407c2 100644 --- a/cmdlet/file/archive.py +++ b/cmdlet/file/archive.py @@ -13,7 +13,7 @@ from typing import Any, Dict, List, Sequence, Set from urllib.parse import parse_qs, urlparse 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.utils import extract_hydrus_hash_from_url diff --git a/cmdlet/file/delete.py b/cmdlet/file/delete.py index 4a4387e..e045697 100644 --- a/cmdlet/file/delete.py +++ b/cmdlet/file/delete.py @@ -8,7 +8,7 @@ import sys from pathlib import Path from SYS.logger import debug, log -from ProviderCore.registry import get_plugin +from PluginCore.registry import get_plugin from Store import Store from .. import _shared as sh from SYS import pipeline as ctx diff --git a/cmdlet/file/download.py b/cmdlet/file/download.py index 4a736ac..d790336 100644 --- a/cmdlet/file/download.py +++ b/cmdlet/file/download.py @@ -992,8 +992,8 @@ class Download_File(Cmdlet): def _load_provider_registry() -> Dict[str, Any]: """Lightweight accessor for plugin helpers without hard dependencies.""" try: - from ProviderCore import registry as provider_registry # type: ignore - from ProviderCore.base import SearchResult # type: ignore + from PluginCore import registry as provider_registry # type: ignore + from PluginCore.base import SearchResult # type: ignore return { "get_plugin": getattr(provider_registry, "get_plugin", None), diff --git a/cmdlet/file/merge.py b/cmdlet/file/merge.py index c2ac466..081cbb2 100644 --- a/cmdlet/file/merge.py +++ b/cmdlet/file/merge.py @@ -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: try: - from ProviderCore.registry import get_plugin_for_url + from PluginCore.registry import get_plugin_for_url expanded: List[Dict[str, Any]] = [] downloaded_any = False diff --git a/cmdlet/file/search.py b/cmdlet/file/search.py index b0e4aef..94860c8 100644 --- a/cmdlet/file/search.py +++ b/cmdlet/file/search.py @@ -15,7 +15,7 @@ from urllib.parse import urlparse, parse_qs, unquote, urljoin from SYS.logger import log, debug, debug_panel 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 ( show_plugin_config_panel, show_store_config_panel, diff --git a/cmdlet/metadata/get_relationship.py b/cmdlet/metadata/get_relationship.py index 628c402..22719c1 100644 --- a/cmdlet/metadata/get_relationship.py +++ b/cmdlet/metadata/get_relationship.py @@ -5,7 +5,7 @@ import sys from SYS.detail_view_helpers import create_detail_view, prepare_detail_metadata 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.selection_builder import build_hash_store_selection from SYS.result_publication import publish_result_table diff --git a/cmdlet/metadata/relationship_add.py b/cmdlet/metadata/relationship_add.py index 93ac2db..3fe4301 100644 --- a/cmdlet/metadata/relationship_add.py +++ b/cmdlet/metadata/relationship_add.py @@ -9,7 +9,7 @@ import sys from SYS.logger import log 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 .. import _shared as sh diff --git a/cmdlet/metadata/tag_get.py b/cmdlet/metadata/tag_get.py index 7a0d9b1..7016932 100644 --- a/cmdlet/metadata/tag_get.py +++ b/cmdlet/metadata/tag_get.py @@ -14,18 +14,18 @@ import sys 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. -_METADATA_PROVIDER_MOD: Optional[Any] = None +_METADATA_PLUGIN_MOD: Optional[Any] = None def _mp() -> Any: - """Return the (lazily imported) plugins.metadata_provider module.""" - global _METADATA_PROVIDER_MOD - if _METADATA_PROVIDER_MOD is None: - import plugins.metadata_provider as _m - _METADATA_PROVIDER_MOD = _m - return _METADATA_PROVIDER_MOD + """Return the (lazily imported) plugins.metadata_plugin module.""" + global _METADATA_PLUGIN_MOD + if _METADATA_PLUGIN_MOD is None: + import plugins.metadata_plugin as _m + _METADATA_PLUGIN_MOD = _m + return _METADATA_PLUGIN_MOD from pathlib import Path diff --git a/cmdlet/provider_table.py b/cmdlet/plugin_table.py similarity index 93% rename from cmdlet/provider_table.py rename to cmdlet/plugin_table.py index 23caafd..7890673 100644 --- a/cmdlet/provider_table.py +++ b/cmdlet/plugin_table.py @@ -42,7 +42,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: select_raw = parsed.get("select") try: - provider = get_plugin(plugin_name) + plugin = get_plugin(plugin_name) except Exception: log(f"Unknown plugin: {plugin_name}", file=sys.stderr) return 1 @@ -52,7 +52,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: if use_sample: # Try to locate SAMPLE_ITEMS in the adapter's module (convention only) try: - mod = __import__(provider.adapter.__module__, fromlist=["*"]) + mod = __import__(plugin.adapter.__module__, fromlist=["*"]) items = getattr(mod, "SAMPLE_ITEMS", None) if items is None: 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 try: - table = provider.build_table(items) + table = plugin.build_table(items) 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 # Emit rows for downstream pipeline consumption (pipable behavior). try: - for item in provider.serialize_rows(table.rows): + for item in plugin.serialize_rows(table.rows): try: ctx.emit(item) except Exception: @@ -115,7 +115,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: return 1 selected = table.rows[select_idx] - sel_args = provider.selection_args(selected) + sel_args = plugin.selection_args(selected) if not run_cmd: # Print selection args for caller diff --git a/cmdnat/_status_shared.py b/cmdnat/_status_shared.py index d026356..6a1731d 100644 --- a/cmdnat/_status_shared.py +++ b/cmdnat/_status_shared.py @@ -41,7 +41,7 @@ def _provider_config_map(config: dict) -> dict[str, Any]: def _iter_registered_plugin_infos() -> tuple[Any, ...]: try: - from ProviderCore.registry import REGISTRY + from PluginCore.registry import REGISTRY return tuple( sorted( diff --git a/cmdnat/config.py b/cmdnat/config.py index 4c801cc..50e5c23 100644 --- a/cmdnat/config.py +++ b/cmdnat/config.py @@ -12,6 +12,13 @@ from SYS.config import ( ) from SYS.database import LOG_DB_PATH, db 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.result_table import Table from cmdnat._parsing import ( @@ -26,6 +33,7 @@ from cmdnat._parsing import ( _PREFERENCES_BROWSE_PATH = "__preferences__" _PLUGINS_BROWSE_PATH = "__plugins__" _PLUGIN_CATEGORY_KEYS = ("plugin", "provider", "tool") +_CREATE_INSTANCE_FLAG = "-create-instance" _KNOWN_SECTION_LABELS = { "plugin": "Plugins", "provider": "Plugins", @@ -52,6 +60,18 @@ _SENSITIVE_CONFIG_KEYS = { "secret", "token", } +_CONFIG_ITEM_FIELDS = ( + "kind", + "key", + "title", + "browse_path", + "name", + "value", + "value_display", + "type", + "display_path", + "instance_target", +) CMDLET = Cmdlet( name=".config", @@ -271,6 +291,168 @@ def _format_config_entry_count(value: Any) -> str: 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 ", + "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]]: branches: List[tuple[str, str, Any]] = [] 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 +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]]: 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() if not key: continue @@ -299,7 +513,7 @@ def _collect_plugin_root_items(config_data: Dict[str, Any]) -> List[Dict[str, An "browse_path": f"{category}.{name}", "summary": _format_config_entry_count(value), "type": "section", - "description": "Plugin configuration", + "description": "Plugin configuration" if is_configured else "Plugin configuration (available to configure)", } continue @@ -337,8 +551,22 @@ def _resolve_config_branch( 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 - for part in text.split("."): + for part in parts: if not isinstance(current, dict): return None 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]]: items: List[Dict[str, Any]] = [] visible_entries = _visible_config_entries(config_data) @@ -444,7 +732,13 @@ def _build_nested_config_items( section_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 + 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): 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()) 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( @@ -493,12 +789,31 @@ def _build_config_header_lines(browse_path: Optional[str]) -> List[str]: return [ "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 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 to update a setting. After creating an instance, set its path, credentials, or other fields here. Use @.. to go back.", + ] return [ f"Path: {_format_config_path_label(text)}", "Use @N on a section to drill in. Use @N | .config 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]: 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] if idx < 0 or idx >= len(items): return None - item = items[idx] - if isinstance(item, dict): - return item + return _normalize_config_item(items[idx]) + + +def _normalize_config_item(candidate: Any) -> Optional[Dict[str, Any]]: + if candidate is None: + return None 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: - value = getattr(item, key, None) + extra = getattr(candidate, "extra", None) except Exception: - value = None - if value is not None: - normalized[key] = value + extra = None + if isinstance(extra, dict): + 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 @@ -573,6 +919,11 @@ def _show_config_table( idx, [".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_current_stage_table(table) @@ -602,6 +953,15 @@ def _resolve_direct_browse_path( return _PREFERENCES_BROWSE_PATH if lowered in {"plugins", "plugin", "providers", "provider", "tools", "tool"}: 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) if isinstance(branch, dict): return text @@ -629,27 +989,63 @@ def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int: if 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 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) selection_kind = str((selection_item or {}).get("kind") or "").strip().lower() 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_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: 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 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: new_value = value_from_pipe or _extract_selected_update_value(args) if new_value is not None: new_value = _strip_value_quotes(new_value) + target_key = _resolve_update_key(current_config, selection_key) try: - set_nested_config(current_config, selection_key, new_value) - _save_updated_config(current_config, selection_key) + set_nested_config(current_config, target_key, new_value) + _save_updated_config(current_config, target_key) print(f"Updated '{selection_display_path}' to '{new_value}'") return 0 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}") return 1 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ec4824f..b51d0b7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,8 +2,8 @@ ## 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. -- **tests:** Add `tests/test_provider_author_examples.py` validating example plugin registration and adapter behavior. The test file keeps its legacy name for compatibility. -- **notes:** Existing example plugins (`plugins/example_provider.py`, `plugins/vimm/__init__.py`) are referenced as canonical patterns. +- **tests:** Add plugin-author example coverage validating example plugin registration and adapter behavior. +- **notes:** Existing example plugins (`plugins/example_plugin.py`, `plugins/vimm/__init__.py`) are referenced as canonical patterns. diff --git a/docs/PR_PROVIDER_AUTHORING.md b/docs/PR_PLUGIN_AUTHORING.md similarity index 58% rename from docs/PR_PROVIDER_AUTHORING.md rename to docs/PR_PLUGIN_AUTHORING.md index ec6ec7a..8663a67 100644 --- a/docs/PR_PROVIDER_AUTHORING.md +++ b/docs/PR_PLUGIN_AUTHORING.md @@ -1,9 +1,9 @@ PR Title: docs: Add plugin authoring doc, examples, and tests 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`. -- 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: - Provide a short, focused Quick Start to help contributors author plugins that integrate with the strict ResultTable API. diff --git a/docs/provider_authoring.md b/docs/plugin_authoring.md similarity index 81% rename from docs/provider_authoring.md rename to docs/plugin_authoring.md index 37be18b..93bab9b 100644 --- a/docs/provider_authoring.md +++ b/docs/plugin_authoring.md @@ -5,16 +5,15 @@ ResultTable API: adapters yield `ResultModel` instances, and plugins register via `SYS.result_table_adapters.register_plugin` with columns and a `selection_fn`. -Note: this file keeps its historical `provider_authoring` name, but the public -terminology is plugin-first. Some internal classes and metadata fields still use -`Provider` naming. +The public terminology is plugin-first, even though some internal classes and +metadata fields still use `Provider` naming. --- ## Quick summary - Plugins register a plugin adapter, a `columns` definition, and a `selection_fn`. - `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 - 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 -from ProviderCore.base import Provider -from SYS.provider_helpers import TableProviderMixin +from PluginCore.base import Provider +from SYS.plugin_helpers import TablePluginMixin -class MyTablePlugin(TableProviderMixin, Provider): +class MyTablePlugin(TablePluginMixin, Provider): URL = ("https://example.org/search",) def validate(self) -> bool: @@ -94,8 +93,8 @@ class MyTablePlugin(TableProviderMixin, Provider): return self.search_table_from_url(url, limit=limit) ``` -`TableProviderMixin.search_table_from_url` returns -`ProviderCore.base.SearchResult` entries. If you want to integrate the plugin +`TablePluginMixin.search_table_from_url` returns +`PluginCore.base.SearchResult` entries. If you want to integrate the plugin with the strict `ResultTable` registry, add a small adapter that converts `SearchResult` to `ResultModel` and register it using `register_plugin`. @@ -128,22 +127,22 @@ Example test skeleton: ```py from SYS.result_table_adapters import get_plugin -from plugins import example_provider +from plugins import example_plugin def test_example_plugin_registration(): 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 cols = plugin.get_columns(rows) 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 ``` --- ## References and examples -- Read `plugins/example_provider.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. -- See `docs/provider_guide.md` for a broader plugin development checklist. +- 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 `TablePluginMixin` and converts `SearchResult` to `ResultModel` for registration. +- See `docs/plugin_guide.md` for a broader plugin development checklist. diff --git a/docs/provider_guide.md b/docs/plugin_guide.md similarity index 92% rename from docs/provider_guide.md rename to docs/plugin_guide.md index 4532b3c..4d47f54 100644 --- a/docs/provider_guide.md +++ b/docs/plugin_guide.md @@ -4,9 +4,8 @@ This guide describes how to write, test, and register a plugin so the application can discover and use it as a pluggable component. -Note: this file keeps its historical `provider_guide` name, but the public -model is plugin-first. Some runtime classes still use `Provider` naming -internally. +The public model is plugin-first, even though the internal base class still +uses `Provider` naming. Keep plugin code small, focused, and well-tested. Bundled plugins and drop-in plugins share the same `plugins/` layout. @@ -15,7 +14,7 @@ plugins share the same `plugins/` layout. ## Anatomy of a plugin 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: - `class MyPlugin(Provider):` subclasses the current internal base plugin class. @@ -31,7 +30,7 @@ Optional but common: --- ## SearchResult -Use `ProviderCore.base.SearchResult` to describe results returned by `search()`. +Use `PluginCore.base.SearchResult` to describe results returned by `search()`. Important fields: - `table` (str): plugin table name @@ -55,7 +54,7 @@ Return a list of `SearchResult(...)` objects or simple dicts convertible with `. Example: ```python -from ProviderCore.base import Provider, SearchResult +from PluginCore.base import Provider, SearchResult class HelloPlugin(Provider): @@ -132,7 +131,7 @@ pytest -q - 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`. - 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 concept is a plugin instance. Some stored config still lives under legacy key diff --git a/docs/result_table.md b/docs/result_table.md index ad64ea9..5724517 100644 --- a/docs/result_table.md +++ b/docs/result_table.md @@ -181,4 +181,4 @@ another, such as artist to discography drill-in. - ResultTable implementation: `SYS/result_table.py` - Pipeline helpers: `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` diff --git a/plugins/README.md b/plugins/README.md index b4c93cf..8d0a0a8 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -29,13 +29,13 @@ drop-in plugin search paths are: Plugin rules: - 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. Example skeleton: ```python -from ProviderCore.base import Provider, SearchResult +from PluginCore.base import Provider, SearchResult class MyPlugin(Provider): diff --git a/plugins/alldebrid/__init__.py b/plugins/alldebrid/__init__.py index 6a46b7f..0320030 100644 --- a/plugins/alldebrid/__init__.py +++ b/plugins/alldebrid/__init__.py @@ -13,8 +13,8 @@ from urllib.parse import urlparse from API.HTTP import HTTPClient, _download_direct_file from plugins.alldebrid.api import AllDebridClient, parse_magnet_or_hash, is_torrent_file -from ProviderCore.base import Provider, SearchResult -from SYS.provider_helpers import TableProviderMixin +from PluginCore.base import Provider, SearchResult +from SYS.plugin_helpers import TablePluginMixin from SYS.item_accessors import get_field as _extract_value from SYS.utils import sanitize_filename from SYS.logger import log, debug, debug_panel @@ -637,7 +637,7 @@ def adjust_output_dir_for_alldebrid( return output_dir -class AllDebrid(TableProviderMixin, Provider): +class AllDebrid(TablePluginMixin, Provider): """AllDebrid account provider with magnet folder/file browsing and downloads. This provider uses the new table system (strict ResultTable adapter pattern) for diff --git a/plugins/bandcamp/__init__.py b/plugins/bandcamp/__init__.py index be67208..647335f 100644 --- a/plugins/bandcamp/__init__.py +++ b/plugins/bandcamp/__init__.py @@ -4,7 +4,7 @@ import sys from urllib.parse import urlparse 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 tool.playwright import PlaywrightTool diff --git a/plugins/example_provider.py b/plugins/example_plugin.py similarity index 95% rename from plugins/example_provider.py rename to plugins/example_plugin.py index f68cf53..9b01df5 100644 --- a/plugins/example_provider.py +++ b/plugins/example_plugin.py @@ -1,11 +1,11 @@ """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 (`render_table`) for demonstration. Run this to see sample output: - python -m Provider.example_provider + python -m plugins.example_plugin Example usage (piped selector): 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]: - """Convert provider-specific items into `ResultModel` instances. + """Convert plugin-specific items into `ResultModel` instances. This adapter enforces the strict API requirement: it yields only `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 -# 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 @@ -112,7 +112,7 @@ register_plugin( adapter, columns=columns_factory, selection_fn=selection_fn, - metadata={"description": "Example provider demonstrating dynamic columns and selectors"}, + metadata={"description": "Example plugin demonstrating dynamic columns and selectors"}, ) diff --git a/plugins/fileio/__init__.py b/plugins/fileio/__init__.py index 45efcc5..817ab3f 100644 --- a/plugins/fileio/__init__.py +++ b/plugins/fileio/__init__.py @@ -4,7 +4,7 @@ import os import sys from typing import Any, Dict, List, Optional -from ProviderCore.base import Provider +from PluginCore.base import Provider from SYS.logger import log diff --git a/plugins/ftp/__init__.py b/plugins/ftp/__init__.py index 6727028..db1c339 100644 --- a/plugins/ftp/__init__.py +++ b/plugins/ftp/__init__.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple 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: diff --git a/plugins/hello/__init__.py b/plugins/hello/__init__.py index 1ec90c3..b052c46 100644 --- a/plugins/hello/__init__.py +++ b/plugins/hello/__init__.py @@ -5,7 +5,7 @@ This minimal plugin demonstrates the typical hooks a plugin may implement: - `search()` to return `SearchResult` items - `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 @@ -13,7 +13,7 @@ from __future__ import annotations from pathlib import Path from typing import Any, Dict, List, Optional -from ProviderCore.base import Provider, SearchResult +from PluginCore.base import Provider, SearchResult class HelloProvider(Provider): diff --git a/plugins/hifi/__init__.py b/plugins/hifi/__init__.py index 1144d2e..13d10c0 100644 --- a/plugins/hifi/__init__.py +++ b/plugins/hifi/__init__.py @@ -16,7 +16,7 @@ from plugins.tidal.api import ( extract_artists, 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 plugins.tidal_manifest import resolve_tidal_manifest_path from SYS import pipeline as pipeline_context diff --git a/plugins/internetarchive/__init__.py b/plugins/internetarchive/__init__.py index a7f1969..ed68fc4 100644 --- a/plugins/internetarchive/__init__.py +++ b/plugins/internetarchive/__init__.py @@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional from urllib.parse import quote, unquote, urlparse 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.logger import log from SYS.config import get_provider_block diff --git a/plugins/libgen/__init__.py b/plugins/libgen/__init__.py index 0f4469f..cd633cf 100644 --- a/plugins/libgen/__init__.py +++ b/plugins/libgen/__init__.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple 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.logger import log, debug from SYS.models import ProgressBar @@ -383,7 +383,7 @@ def _enrich_book_tags_from_isbn(isbn: str, # 2) isbnsearch metadata plugin fallback. try: - from plugins.metadata_provider import get_metadata_plugin + from plugins.metadata_plugin import get_metadata_plugin provider = get_metadata_plugin("isbnsearch", config or {}) @@ -660,7 +660,7 @@ class Libgen(Provider): "libgen": ["download-file"], } # 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 = ( "libgen.gl", "libgen.li", @@ -1056,7 +1056,7 @@ class Libgen(Provider): def download_url(self, url: str, output_dir: Path) -> Optional[Path]: """Download a direct LibGen URL using the regular mirror logic.""" try: - from ProviderCore.base import SearchResult + from PluginCore.base import SearchResult sr = SearchResult( table="libgen", title="libgen", diff --git a/plugins/loc/__init__.py b/plugins/loc/__init__.py index 37d3c82..6ec44e7 100644 --- a/plugins/loc/__init__.py +++ b/plugins/loc/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional 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.logger import log diff --git a/plugins/local/__init__.py b/plugins/local/__init__.py index 10d5db5..c59feac 100644 --- a/plugins/local/__init__.py +++ b/plugins/local/__init__.py @@ -4,7 +4,7 @@ import shutil from pathlib import Path 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.utils import sanitize_filename, sha256_file, unique_path diff --git a/plugins/matrix/__init__.py b/plugins/matrix/__init__.py index 7da84bd..b381d08 100644 --- a/plugins/matrix/__init__.py +++ b/plugins/matrix/__init__.py @@ -11,8 +11,8 @@ from urllib.parse import quote from API.requests_client import get_requests_session from SYS.utils import ffprobe as probe_media_metadata -from ProviderCore.base import Provider, SearchResult -from SYS.provider_helpers import TableProviderMixin +from PluginCore.base import Provider, SearchResult +from SYS.plugin_helpers import TablePluginMixin from SYS.logger import log _MATRIX_INIT_CHECK_CACHE: Dict[str, @@ -276,7 +276,7 @@ def _matrix_health_check(*, return False, str(exc) -class Matrix(TableProviderMixin, Provider): +class Matrix(TablePluginMixin, Provider): """Matrix (Element) room provider with file uploads and selection. This provider uses the new table system (strict ResultTable adapter pattern) for diff --git a/plugins/matrix/commands.py b/plugins/matrix/commands.py index a56c3a1..e040614 100644 --- a/plugins/matrix/commands.py +++ b/plugins/matrix/commands.py @@ -22,7 +22,7 @@ from SYS.command_parsing import ( normalize_to_list as _normalize_to_list, ) 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_TEXT_KEY = "matrix_pending_text" diff --git a/plugins/metadata_provider.py b/plugins/metadata_plugin.py similarity index 99% rename from plugins/metadata_provider.py rename to plugins/metadata_plugin.py index c9db1d2..8c633fe 100644 --- a/plugins/metadata_provider.py +++ b/plugins/metadata_plugin.py @@ -10,7 +10,7 @@ import subprocess from API.HTTP import HTTPClient from API.requests_client import get_requests_session -from ProviderCore.base import SearchResult +from PluginCore.base import SearchResult try: from plugins.tidal import Tidal except ImportError: # pragma: no cover - optional diff --git a/plugins/mpv/commands.py b/plugins/mpv/commands.py index f653453..cd4fddb 100644 --- a/plugins/mpv/commands.py +++ b/plugins/mpv/commands.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta from urllib.parse import urlparse, parse_qs from pathlib import Path 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.result_table import Table from plugins.mpv.mpv_ipc import MPV diff --git a/plugins/mpv/lyric.py b/plugins/mpv/lyric.py index d9d3a67..9b96f41 100644 --- a/plugins/mpv/lyric.py +++ b/plugins/mpv/lyric.py @@ -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" if not is_hydrus_backend: try: - from ProviderCore.registry import get_plugin + from PluginCore.registry import get_plugin hydrus_provider = get_plugin("hydrusnetwork", config) checker = getattr(hydrus_provider, "is_backend", None) if hydrus_provider is not None else None diff --git a/plugins/mpv/pipeline_helper.py b/plugins/mpv/pipeline_helper.py index 0b4c7ee..a3cb45b 100644 --- a/plugins/mpv/pipeline_helper.py +++ b/plugins/mpv/pipeline_helper.py @@ -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.repl_queue import enqueue_repl_command # 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 REQUEST_PROP = "user-data/medeia-pipeline-request" diff --git a/plugins/openlibrary/__init__.py b/plugins/openlibrary/__init__.py index 84c93f0..42b871e 100644 --- a/plugins/openlibrary/__init__.py +++ b/plugins/openlibrary/__init__.py @@ -18,11 +18,11 @@ import requests from API.HTTP import HTTPClient 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.cli_syntax import get_field, get_free_text, parse_query from SYS.logger import log -from plugins.metadata_provider import ( +from plugins.metadata_plugin import ( archive_item_metadata_to_tags, 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. - # (Used by ProviderCore.registry.match_provider_name_for_url) + # (Used by PluginCore.registry.match_provider_name_for_url) URL_DOMAINS = ( "openlibrary.org", "archive.org", diff --git a/plugins/podcastindex/__init__.py b/plugins/podcastindex/__init__.py index 46fd75c..2a1f937 100644 --- a/plugins/podcastindex/__init__.py +++ b/plugins/podcastindex/__init__.py @@ -5,7 +5,7 @@ import hashlib from pathlib import Path 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.utils import format_bytes diff --git a/plugins/scp/__init__.py b/plugins/scp/__init__.py index 9bd26bc..aa8b847 100644 --- a/plugins/scp/__init__.py +++ b/plugins/scp/__init__.py @@ -13,7 +13,7 @@ from urllib.parse import quote, unquote, urlparse import paramiko 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: diff --git a/plugins/soulseek/__init__.py b/plugins/soulseek/__init__.py index 5336643..5c8b469 100644 --- a/plugins/soulseek/__init__.py +++ b/plugins/soulseek/__init__.py @@ -11,7 +11,7 @@ import time from pathlib import Path 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.models import ProgressBar diff --git a/plugins/telegram/__init__.py b/plugins/telegram/__init__.py index 4387904..4d40c7f 100644 --- a/plugins/telegram/__init__.py +++ b/plugins/telegram/__init__.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple from urllib.parse import urlparse -from ProviderCore.base import Provider, SearchResult +from PluginCore.base import Provider, SearchResult from SYS.logger import debug _TELEGRAM_DEFAULT_TIMESTAMP_STEM_RE = re.compile( diff --git a/plugins/telegram/commands.py b/plugins/telegram/commands.py index d1199c2..f2b1636 100644 --- a/plugins/telegram/commands.py +++ b/plugins/telegram/commands.py @@ -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.result_table import Table 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" diff --git a/plugins/tidal/__init__.py b/plugins/tidal/__init__.py index 6477776..a9c0aa9 100644 --- a/plugins/tidal/__init__.py +++ b/plugins/tidal/__init__.py @@ -16,7 +16,7 @@ from plugins.tidal.api import ( extract_artists, stringify, ) -from ProviderCore.base import Provider, SearchResult +from PluginCore.base import Provider, SearchResult from SYS.field_access import get_field from plugins.tidal_manifest import resolve_tidal_manifest_path 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]]: """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`). Returns: diff --git a/plugins/torrent/__init__.py b/plugins/torrent/__init__.py index 260f781..4e405a5 100644 --- a/plugins/torrent/__init__.py +++ b/plugins/torrent/__init__.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional import requests 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 try: # Preferred HTML parser from lxml import html as lxml_html diff --git a/plugins/vimm/__init__.py b/plugins/vimm/__init__.py index 9af3d75..948111e 100644 --- a/plugins/vimm/__init__.py +++ b/plugins/vimm/__init__.py @@ -16,14 +16,14 @@ import re from pathlib import Path from API.HTTP import HTTPClient -from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments -from ProviderCore.inline_utils import resolve_filter +from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments +from PluginCore.inline_utils import resolve_filter from SYS.logger import debug, debug_panel -from SYS.provider_helpers import TableProviderMixin +from SYS.plugin_helpers import TablePluginMixin from tool.playwright import PlaywrightTool -class Vimm(TableProviderMixin, Provider): +class Vimm(TablePluginMixin, Provider): """Minimal provider for vimm.net vault listings using TableProvider mixin. NOTES / HOW-TO (selection & auto-download): @@ -142,7 +142,7 @@ class Vimm(TableProviderMixin, Provider): ], "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. INLINE_QUERY_FIELD_CHOICES = QUERY_ARG_CHOICES diff --git a/plugins/ytdlp/__init__.py b/plugins/ytdlp/__init__.py index 2b2b8f3..f0fecaf 100644 --- a/plugins/ytdlp/__init__.py +++ b/plugins/ytdlp/__init__.py @@ -13,8 +13,8 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple from urllib.parse import urlparse -from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments -from SYS.provider_helpers import TableProviderMixin +from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments +from SYS.plugin_helpers import TablePluginMixin from SYS.logger import debug, log from SYS.models import DownloadError, DownloadMediaResult, DownloadOptions from SYS.payload_builders import build_file_result_payload, build_table_result_payload @@ -501,7 +501,7 @@ def _build_pipe_objects( return pipe_objects -class ytdlp(TableProviderMixin, Provider): +class ytdlp(TablePluginMixin, Provider): """yt-dlp-backed search and direct download plugin.""" PLUGIN_NAME = "ytdlp" diff --git a/plugins/zeroxzero/__init__.py b/plugins/zeroxzero/__init__.py index 18a3f9d..6fc09eb 100644 --- a/plugins/zeroxzero/__init__.py +++ b/plugins/zeroxzero/__init__.py @@ -4,7 +4,7 @@ import os import sys from typing import Any -from ProviderCore.base import Provider +from PluginCore.base import Provider from SYS.logger import log diff --git a/readme.md b/readme.md index 75b4a3c..32698fd 100644 --- a/readme.md +++ b/readme.md @@ -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 `. - Bundled and external plugins use the same `plugins//` layout. - 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. @@ -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) - [plugins/README.md](plugins/README.md) -- [docs/provider_guide.md](docs/provider_guide.md) -- [docs/provider_authoring.md](docs/provider_authoring.md) +- [docs/plugin_guide.md](docs/plugin_guide.md) +- [docs/plugin_authoring.md](docs/plugin_authoring.md) - [docs/ftp_plugin_tutorial.md](docs/ftp_plugin_tutorial.md) - [docs/scp_plugin_tutorial.md](docs/scp_plugin_tutorial.md) - [docs/BOOTSTRAP_TROUBLESHOOTING.md](docs/BOOTSTRAP_TROUBLESHOOTING.md) diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index d67eede..29c3a93 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -121,8 +121,7 @@ packages = [ "cmdnat", "API", "Store", - "ProviderCore", - "Provider", + "PluginCore", "SYS", "tool", "TUI",