continuing refactor
This commit is contained in:
+90
-5
@@ -161,6 +161,17 @@ class Provider(ABC):
|
||||
# generic "file host" plugins via `add-file -plugin ...`.
|
||||
EXPOSE_AS_FILE_PROVIDER: bool = True
|
||||
|
||||
# Set to True for plugins that support multiple named instances in config.
|
||||
# When True, config is expected at config["plugin"][<PLUGIN_NAME>][<instance_name>]
|
||||
# rather than config["plugin"][<PLUGIN_NAME>] directly.
|
||||
# Examples: hydrusnetwork (home/work), matrix (personal/work), ftp.
|
||||
MULTI_INSTANCE: bool = False
|
||||
|
||||
# Declare which top-level cmdlet names this plugin handles.
|
||||
# Cmdlet dispatch and capability discovery use this to route operations.
|
||||
# Example: frozenset({"add-file", "get-file", "get-tag", "search-file"})
|
||||
SUPPORTED_CMDLETS: frozenset = frozenset()
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self.config = config or {}
|
||||
self.name = str(
|
||||
@@ -312,11 +323,21 @@ class Provider(ABC):
|
||||
def plugin_config_root(self) -> Dict[str, Any]:
|
||||
if not isinstance(self.config, dict):
|
||||
return {}
|
||||
provider_cfg = self.config.get("provider")
|
||||
if not isinstance(provider_cfg, dict):
|
||||
return {}
|
||||
entry = provider_cfg.get(self.plugin_config_key())
|
||||
return dict(entry) if isinstance(entry, dict) else {}
|
||||
# Check plugin/provider section first (preferred new format)
|
||||
for section in ("plugin", "provider"):
|
||||
section_cfg = self.config.get(section)
|
||||
if isinstance(section_cfg, dict):
|
||||
entry = section_cfg.get(self.plugin_config_key())
|
||||
if isinstance(entry, dict):
|
||||
return dict(entry)
|
||||
# Backward compat: fall back to store section.
|
||||
# store config uses {type: {instance: {key: val}}} — one level deeper.
|
||||
store_cfg = self.config.get("store")
|
||||
if isinstance(store_cfg, dict):
|
||||
store_entries = store_cfg.get(self.plugin_config_key())
|
||||
if isinstance(store_entries, dict):
|
||||
return dict(store_entries)
|
||||
return {}
|
||||
|
||||
def plugin_instance_configs(self) -> Dict[str, Dict[str, Any]]:
|
||||
entry = self.plugin_config_root()
|
||||
@@ -599,6 +620,70 @@ class Provider(ABC):
|
||||
"""Upload a file and return a URL or identifier."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support upload")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Storage interface — mirrors Store._base.Store.
|
||||
# Plugins that act as file repositories override these methods.
|
||||
# All raise NotImplementedError by default; override selectively.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_remote(self) -> bool:
|
||||
"""True if this plugin stores files on a remote service."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def prefer_defer_tags(self) -> bool:
|
||||
"""True if tag writes should be deferred until after file ingest."""
|
||||
return False
|
||||
|
||||
def add_file(self, file_path: Path, **kwargs: Any) -> str:
|
||||
"""Ingest a file and return its canonical hash."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support add_file")
|
||||
|
||||
def get_file(self, file_hash: str, **kwargs: Any) -> Optional[Path]:
|
||||
"""Retrieve a stored file by hash, returning a local Path or None."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support get_file")
|
||||
|
||||
def get_metadata(self, file_hash: str, **kwargs: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Return metadata dict for a stored file."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support get_metadata")
|
||||
|
||||
def get_tag(self, file_identifier: str, **kwargs: Any) -> Tuple[List[str], str]:
|
||||
"""Return (tags, hash) for a stored file identifier."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support get_tag")
|
||||
|
||||
def add_tag(self, file_identifier: str, tags: List[str], **kwargs: Any) -> bool:
|
||||
"""Add tags to a stored file. Returns True on success."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support add_tag")
|
||||
|
||||
def delete_tag(self, file_identifier: str, tags: List[str], **kwargs: Any) -> bool:
|
||||
"""Remove tags from a stored file. Returns True on success."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support delete_tag")
|
||||
|
||||
def get_url(self, file_identifier: str, **kwargs: Any) -> List[str]:
|
||||
"""Return associated URLs for a stored file."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support get_url")
|
||||
|
||||
def add_url(self, file_identifier: str, urls: List[str], **kwargs: Any) -> bool:
|
||||
"""Associate URLs with a stored file. Returns True on success."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support add_url")
|
||||
|
||||
def delete_url(self, file_identifier: str, urls: List[str], **kwargs: Any) -> bool:
|
||||
"""Remove URL associations from a stored file. Returns True on success."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support delete_url")
|
||||
|
||||
def get_note(self, file_identifier: str, **kwargs: Any) -> Dict[str, str]:
|
||||
"""Return notes dict (name -> text) for a stored file."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support get_note")
|
||||
|
||||
def set_note(self, file_identifier: str, name: str, text: str, **kwargs: Any) -> bool:
|
||||
"""Write a named note on a stored file. Returns True on success."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support set_note")
|
||||
|
||||
def delete_note(self, file_identifier: str, name: str, **kwargs: Any) -> bool:
|
||||
"""Delete a named note from a stored file. Returns True on success."""
|
||||
raise NotImplementedError(f"Plugin '{self.name}' does not support delete_note")
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Check if the plugin is available and properly configured."""
|
||||
|
||||
|
||||
@@ -150,6 +150,20 @@ class PluginInfo:
|
||||
exposed = True
|
||||
return exposed and _class_supports_method(self.plugin_class, "upload", Provider.upload)
|
||||
|
||||
@property
|
||||
def is_multi_instance(self) -> bool:
|
||||
"""True if the plugin declares MULTI_INSTANCE = True."""
|
||||
return bool(getattr(self.plugin_class, "MULTI_INSTANCE", False))
|
||||
|
||||
@property
|
||||
def supported_cmdlets(self) -> frozenset:
|
||||
"""Frozenset of cmdlet names this plugin declares support for."""
|
||||
raw = getattr(self.plugin_class, "SUPPORTED_CMDLETS", frozenset())
|
||||
try:
|
||||
return frozenset(str(c) for c in raw)
|
||||
except Exception:
|
||||
return frozenset()
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""Handles discovery, registration, and lookup of built-in and external plugins."""
|
||||
@@ -433,6 +447,42 @@ class PluginRegistry:
|
||||
def has_name(self, name: str) -> bool:
|
||||
return self.get(name) is not None
|
||||
|
||||
def get_plugins_for_cmdlet(self, cmdlet_name: str) -> List[PluginInfo]:
|
||||
"""Return all plugins that declare support for the given cmdlet name."""
|
||||
self.discover()
|
||||
target = str(cmdlet_name or "").strip().lower()
|
||||
return [
|
||||
info for info in self._infos.values()
|
||||
if target in info.supported_cmdlets
|
||||
]
|
||||
|
||||
def list_storage_plugin_instances(
|
||||
self,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Return {plugin_name: [instance_name, ...]} for all MULTI_INSTANCE storage plugins.
|
||||
|
||||
Instance names come from the plugin's resolved config (plugin/provider/store sections).
|
||||
Plugins with no configured instances are omitted.
|
||||
"""
|
||||
self.discover()
|
||||
result: Dict[str, List[str]] = {}
|
||||
for info in self._infos.values():
|
||||
if not info.is_multi_instance:
|
||||
continue
|
||||
if not info.supported_cmdlets.intersection(
|
||||
{"add-file", "get-file", "get-tag", "add-tag"}
|
||||
):
|
||||
continue
|
||||
try:
|
||||
instance = info.plugin_class(config or {})
|
||||
instances = instance.configured_instances()
|
||||
if instances:
|
||||
result[info.canonical_name] = instances
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
def _sync_subclasses(self) -> None:
|
||||
"""Walk all plugin subclasses in memory and register them."""
|
||||
def _walk(cls: Type[Provider]) -> None:
|
||||
@@ -691,6 +741,39 @@ def list_plugin_names_with_capability(capability: str) -> List[str]:
|
||||
)
|
||||
|
||||
|
||||
def list_configured_plugin_names_with_capability(
|
||||
capability: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> List[str]:
|
||||
"""Return plugin names that support `capability` AND have configuration present.
|
||||
|
||||
For MULTI_INSTANCE plugins (e.g. hydrusnetwork, ftp) the plugin must have at
|
||||
least one configured instance. For single-instance plugins the plugin's section
|
||||
must exist under config["plugin"] or config["provider"].
|
||||
"""
|
||||
cfg = config or {}
|
||||
plugin_section: Dict[str, Any] = cfg.get("plugin") or {} # type: ignore[assignment]
|
||||
provider_section: Dict[str, Any] = cfg.get("provider") or {} # type: ignore[assignment]
|
||||
|
||||
result: List[str] = []
|
||||
for info in REGISTRY.iter_plugins():
|
||||
if not _info_supports_capability(info, capability):
|
||||
continue
|
||||
name = info.canonical_name
|
||||
if info.is_multi_instance:
|
||||
try:
|
||||
instances = info.plugin_class(cfg).configured_instances()
|
||||
if instances:
|
||||
result.append(name)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
pname = name.lower()
|
||||
if isinstance(plugin_section.get(pname), dict) or isinstance(provider_section.get(pname), dict):
|
||||
result.append(name)
|
||||
return sorted(result)
|
||||
|
||||
|
||||
def match_plugin_name_for_url(url: str) -> Optional[str]:
|
||||
raw_url = str(url or "").strip()
|
||||
raw_url_lower = raw_url.lower()
|
||||
@@ -907,6 +990,7 @@ __all__ = [
|
||||
"get_plugin_with_capability",
|
||||
"list_plugins_with_capability",
|
||||
"list_plugin_names_with_capability",
|
||||
"list_configured_plugin_names_with_capability",
|
||||
"match_plugin_name_for_url",
|
||||
"get_plugin_for_url",
|
||||
"list_selection_url_prefixes",
|
||||
|
||||
Reference in New Issue
Block a user