huge refactor of plugin system

This commit is contained in:
2026-04-30 18:56:22 -07:00
parent ea3ead248b
commit be5a11da97
99 changed files with 7603 additions and 11320 deletions
+12 -7
View File
@@ -5,6 +5,7 @@ from importlib import import_module, reload as reload_module
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
logger = logging.getLogger(__name__)
@@ -71,17 +72,22 @@ def _normalize_mod_name(mod_name: str) -> str:
def import_cmd_module(mod_name: str, *, reload_loaded: bool = False):
"""Import a cmdlet/native module from cmdnat or cmdlet packages."""
"""Import a cmdlet/command module from legacy or plugin-owned packages."""
normalized = _normalize_mod_name(mod_name)
if not normalized:
return None
for package in ("cmdnat", "cmdlet", None):
for qualified in (
f"plugins.{normalized}.commands",
f"cmdnat.{normalized}",
f"cmdlet.{normalized}",
normalized,
):
try:
# When attempting a bare import (package is None), prefer the repo-local
# `MPV` package for the `mpv` module name so we don't accidentally
# import the third-party `mpv` package (python-mpv) which can raise
# OSError if system libmpv is missing.
if package is None and normalized == "mpv":
if qualified == normalized and normalized == "mpv":
try:
if reload_loaded and "MPV" in sys.modules:
return reload_module(sys.modules["MPV"])
@@ -90,7 +96,6 @@ def import_cmd_module(mod_name: str, *, reload_loaded: bool = False):
# Local MPV package not present; fall back to the normal bare import.
pass
qualified = f"{package}.{normalized}" if package else normalized
if reload_loaded and qualified in sys.modules:
return reload_module(sys.modules[qualified])
return import_module(qualified)
@@ -151,7 +156,7 @@ def get_cmdlet_metadata(
ensure_registry_loaded()
normalized = cmd_name.replace("-", "_")
mod = import_cmd_module(normalized)
data = getattr(mod, "CMDLET", None) if mod else None
data = get_primary_command_object(mod) if mod else None
if data is None:
try:
@@ -161,7 +166,7 @@ def get_cmdlet_metadata(
owner_mod = getattr(reg_fn, "__module__", "")
if owner_mod:
owner = import_module(owner_mod)
data = getattr(owner, "CMDLET", None)
data = get_primary_command_object(owner)
except Exception as exc:
logger.exception("Registry fallback failed while resolving cmdlet %s: %s", cmd_name, exc)
data = None
@@ -371,7 +376,7 @@ def get_cmdlet_arg_choices(
ids = []
if ids:
# Try to resolve names via Provider.matrix if config provides auth info
# Try to resolve names via the Matrix plugin if config provides auth info
try:
hs = matrix_conf.get("homeserver")
token = matrix_conf.get("access_token")
+7 -3
View File
@@ -33,13 +33,17 @@ class CmdletArg:
return value
def to_flags(self) -> tuple[str, ...]:
flags = [f"--{self.name}", f"-{self.name}"]
normalized_name = str(self.name or "").lstrip("-")
if not normalized_name:
return tuple()
flags = [f"--{normalized_name}", f"-{normalized_name}"]
if self.alias:
flags.append(f"-{self.alias}")
if self.type == "flag":
flags.append(f"--no-{self.name}")
flags.append(f"-no{self.name}")
flags.append(f"--no-{normalized_name}")
flags.append(f"-no{normalized_name}")
if self.alias:
flags.append(f"-n{self.alias}")
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from typing import Any, Iterable, List, Optional, Sequence
VALUE_ARG_FLAGS = frozenset({"-value", "--value", "-set-value", "--set-value"})
def extract_piped_value(result: Any) -> Optional[str]:
if isinstance(result, str):
return result.strip() if result.strip() else None
if isinstance(result, (int, float)):
return str(result)
if isinstance(result, dict):
value = result.get("value")
if value is not None:
return str(value).strip()
return None
def extract_arg_value(
args: Sequence[str],
*,
flags: Iterable[str],
allow_positional: bool = False,
) -> Optional[str]:
if not args:
return None
tokens = [str(tok) for tok in args if tok is not None]
normalized_flags = {
str(flag).strip().lower() for flag in flags if str(flag).strip()
}
if not normalized_flags:
return None
for idx, tok in enumerate(tokens):
text = tok.strip()
if not text:
continue
low = text.lower()
if low in normalized_flags and idx + 1 < len(tokens):
candidate = str(tokens[idx + 1]).strip()
if candidate:
return candidate
if "=" in low:
head, value = low.split("=", 1)
if head in normalized_flags and value:
return value.strip()
if not allow_positional:
return None
for tok in tokens:
text = str(tok).strip()
if text and not text.startswith("-"):
return text
return None
def extract_value_arg(args: Sequence[str]) -> Optional[str]:
return extract_arg_value(args, flags=VALUE_ARG_FLAGS, allow_positional=True)
def has_flag(args: Sequence[str], flag: str) -> bool:
try:
want = str(flag or "").strip().lower()
if not want:
return False
return any(str(arg).strip().lower() == want for arg in (args or []))
except Exception:
return False
def normalize_to_list(value: Any) -> List[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
-8
View File
@@ -769,14 +769,6 @@ def set_last_result_table_overlay(
state.display_items = items or []
state.display_subject = subject
def set_last_result_table_preserve_history(
result_table: Optional[Any],
items: Optional[List[Any]] = None,
subject: Optional[Any] = None
) -> None:
"""Compatibility alias for set_last_result_table_overlay."""
set_last_result_table_overlay(result_table, items=items, subject=subject)
def set_last_result_items_only(items: Optional[List[Any]]) -> None:
"""
Store items for @N selection WITHOUT affecting history or saved search data.
+4 -7
View File
@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, List, Optional
from SYS.config import global_config
from ProviderCore.registry import get_plugin_class, list_plugins
from Store.registry import _discover_store_classes, _required_keys_for
from Store.registry import _discover_store_classes, _required_keys_for, _resolve_store_class
logger = logging.getLogger(__name__)
@@ -54,8 +54,7 @@ def _call_schema(owner: Any, label: str) -> List[ConfigField]:
def get_store_schema(store_type: str) -> List[ConfigField]:
classes = _discover_store_classes()
cls = classes.get(str(store_type or "").strip())
cls = _resolve_store_class(str(store_type or "").strip())
if cls is None:
return []
return _call_schema(cls, f"store '{store_type}'")
@@ -115,8 +114,7 @@ def build_default_store_config(store_type: str, instance_name: str) -> Dict[str,
config[key] = field.get("default", "")
return config
classes = _discover_store_classes()
cls = classes.get(str(store_type or "").strip())
cls = _resolve_store_class(str(store_type or "").strip())
if cls is None:
return config
for required_key in _required_keys_for(cls):
@@ -174,8 +172,7 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]:
if normalized_type.startswith("store-"):
store_type = normalized_type.replace("store-", "", 1)
classes = _discover_store_classes()
cls = classes.get(store_type)
cls = _resolve_store_class(store_type)
if cls is not None:
for required_key in _required_keys_for(cls):
_add_key(required_key)
+72 -1
View File
@@ -13,7 +13,7 @@ Features:
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Callable, Set
from typing import Any, Dict, List, Optional, Callable, Set, Tuple
from pathlib import Path
import json
import re
@@ -94,6 +94,42 @@ def _partition_detail_tags(tags: Any) -> tuple[List[str], List[str]]:
return namespace_tags, freeform_tags
def _extract_namespace_sort_values(tags: Any, namespace: str) -> List[str]:
wanted = str(namespace or "").strip().casefold()
if not wanted:
return []
values: List[str] = []
for tag in _normalize_detail_tags(tags):
ns, sep, value = str(tag).partition(":")
if not sep:
continue
if ns.strip().casefold() != wanted:
continue
clean_value = value.strip()
if clean_value:
values.append(clean_value)
return values
def _namespace_sort_key(tags: Any, namespace: str, *, numeric: bool = False) -> Tuple[int, Any, str]:
values = _extract_namespace_sort_values(tags, namespace)
if not values:
return (1, float("inf") if numeric else "", "")
primary = values[0]
if numeric:
match = re.search(r"-?\d+(?:\.\d+)?", primary)
if match:
try:
return (0, float(match.group(0)), primary.casefold())
except Exception:
pass
return (0, float("inf"), primary.casefold())
return (0, primary.casefold(), primary.casefold())
def _chunk_detail_tags(tags: List[str], columns: int) -> List[List[str]]:
column_count = max(1, int(columns or 1))
rows: List[List[str]] = []
@@ -954,6 +990,41 @@ class Table:
return self
def sort_by_tag_namespace(self, namespace: str, *, numeric: bool = False, reverse: bool = False) -> "Table":
"""Sort rows by the first value found for a tag namespace.
Looks first at row payload tag metadata, then falls back to the visible Tag column.
When ``numeric`` is True, the first numeric token inside the namespace value is used.
"""
if getattr(self, "preserve_order", False):
return self
wanted = str(namespace or "").strip()
if not wanted:
return self
def _row_tags(row: Row) -> Any:
payload = getattr(row, "payload", None)
if isinstance(payload, dict):
for key in ("tag", "tags", "tag_summary"):
value = payload.get(key)
if value:
return value
metadata = payload.get("metadata")
if isinstance(metadata, dict):
for key in ("tag", "tags", "tag_summary"):
value = metadata.get(key)
if value:
return value
tag_column = row.get_column("Tag")
return tag_column or []
self.rows.sort(
key=lambda row: _namespace_sort_key(_row_tags(row), wanted, numeric=bool(numeric)),
reverse=bool(reverse),
)
return self
def add_result(self, result: Any) -> "Table":
"""Add a result object (SearchResult, PipeObject, ResultItem, TagItem, or dict) as a row.