updating and refining plugin system refactor
This commit is contained in:
@@ -3,8 +3,41 @@ from __future__ import annotations
|
||||
from typing import Any, Iterable, Optional, Sequence
|
||||
|
||||
|
||||
_ACRONYM_LABELS = {
|
||||
"id": "ID",
|
||||
"ids": "IDs",
|
||||
"url": "URL",
|
||||
"urls": "URLs",
|
||||
"api": "API",
|
||||
"http": "HTTP",
|
||||
"https": "HTTPS",
|
||||
"ftp": "FTP",
|
||||
"ftps": "FTPS",
|
||||
"scp": "SCP",
|
||||
"ssh": "SSH",
|
||||
"ip": "IP",
|
||||
"mpv": "MPV",
|
||||
}
|
||||
|
||||
|
||||
def _labelize_key(key: str) -> str:
|
||||
return str(key or "").replace("_", " ").title()
|
||||
parts = [part for part in str(key or "").replace("_", " ").split() if part]
|
||||
normalized: list[str] = []
|
||||
for part in parts:
|
||||
lowered = part.lower()
|
||||
normalized.append(_ACRONYM_LABELS.get(lowered, part.title()))
|
||||
return " ".join(normalized)
|
||||
|
||||
|
||||
def _has_display_value(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
return bool(text and text.lower() not in {"<null>", "null", "none"})
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return any(_has_display_value(item) for item in value)
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_tags_value(tags: Any) -> Optional[str]:
|
||||
@@ -45,7 +78,7 @@ def prepare_detail_metadata(
|
||||
if str(key).startswith("_") or key in {"selection_action", "selection_args"}:
|
||||
continue
|
||||
label = _labelize_key(str(key))
|
||||
if label not in metadata and value is not None:
|
||||
if label not in metadata and _has_display_value(value):
|
||||
metadata[label] = value
|
||||
|
||||
if title:
|
||||
@@ -62,7 +95,7 @@ def prepare_detail_metadata(
|
||||
metadata["Tags"] = tags_text
|
||||
|
||||
for key, value in (extra_fields or {}).items():
|
||||
if value is not None:
|
||||
if _has_display_value(value):
|
||||
metadata[str(key)] = value
|
||||
|
||||
return metadata
|
||||
@@ -77,6 +110,7 @@ def create_detail_view(
|
||||
init_command: Optional[tuple[str, Sequence[str]]] = None,
|
||||
max_columns: Optional[int] = None,
|
||||
exclude_tags: bool = False,
|
||||
detail_order: Optional[Sequence[str]] = None,
|
||||
value_case: Optional[str] = "preserve",
|
||||
perseverance: bool = True,
|
||||
) -> Any:
|
||||
@@ -87,6 +121,8 @@ def create_detail_view(
|
||||
kwargs["max_columns"] = max_columns
|
||||
if exclude_tags:
|
||||
kwargs["exclude_tags"] = True
|
||||
if detail_order is not None:
|
||||
kwargs["detail_order"] = list(detail_order)
|
||||
|
||||
table = ItemDetailView(title, **kwargs)
|
||||
if table_name:
|
||||
@@ -101,4 +137,46 @@ def create_detail_view(
|
||||
if init_command:
|
||||
name, args = init_command
|
||||
table = table.init_command(name, list(args))
|
||||
return table
|
||||
return table
|
||||
|
||||
|
||||
def render_selection_detail_view(
|
||||
*,
|
||||
ctx: Any,
|
||||
item: Any,
|
||||
title: str,
|
||||
metadata: dict[str, Any],
|
||||
table_name: Optional[str] = None,
|
||||
detail_order: Optional[Sequence[str]] = None,
|
||||
value_case: Optional[str] = "preserve",
|
||||
exclude_tags: bool = False,
|
||||
) -> bool:
|
||||
from SYS.rich_display import stdout_console
|
||||
|
||||
detail_view = create_detail_view(
|
||||
title,
|
||||
metadata,
|
||||
table_name=table_name,
|
||||
detail_order=detail_order,
|
||||
value_case=value_case,
|
||||
exclude_tags=exclude_tags,
|
||||
)
|
||||
|
||||
payload = item.to_dict() if hasattr(item, "to_dict") else item
|
||||
try:
|
||||
if hasattr(ctx, "set_last_result_items_only") and isinstance(payload, dict):
|
||||
ctx.set_last_result_items_only([payload])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
try:
|
||||
detail_view.title = ""
|
||||
detail_view.header_lines = []
|
||||
except Exception:
|
||||
pass
|
||||
stdout_console().print()
|
||||
stdout_console().print(detail_view.to_rich())
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
+37
-7
@@ -1478,13 +1478,18 @@ class PipelineExecutor:
|
||||
config: Any,
|
||||
selected_items: list,
|
||||
*,
|
||||
stage_is_last: bool
|
||||
stage_is_last: bool,
|
||||
source_command: Any = None,
|
||||
prefer_detail_fallback: bool = False,
|
||||
) -> bool:
|
||||
if not stage_is_last:
|
||||
return False
|
||||
|
||||
candidates: list[str] = []
|
||||
seen: set[str] = set()
|
||||
current_table = None
|
||||
table_meta = None
|
||||
table_type = ""
|
||||
|
||||
def _add(value) -> None:
|
||||
try:
|
||||
@@ -1504,6 +1509,8 @@ class PipelineExecutor:
|
||||
table if current_table and hasattr(current_table,
|
||||
"table") else None
|
||||
)
|
||||
if current_table and hasattr(current_table, "table"):
|
||||
table_type = str(getattr(current_table, "table", "") or "").strip()
|
||||
|
||||
# Prefer an explicit plugin hint from table metadata when available.
|
||||
# This keeps @N selectors working even when row payloads don't carry a
|
||||
@@ -1516,6 +1523,7 @@ class PipelineExecutor:
|
||||
)
|
||||
except Exception:
|
||||
meta = None
|
||||
table_meta = meta if isinstance(meta, dict) else None
|
||||
if isinstance(meta, dict):
|
||||
_add(meta.get("plugin"))
|
||||
_add(meta.get("provider"))
|
||||
@@ -1585,6 +1593,26 @@ class PipelineExecutor:
|
||||
if handled:
|
||||
return True
|
||||
|
||||
if prefer_detail_fallback:
|
||||
detail_renderer = getattr(provider, "show_selection_details", None)
|
||||
if callable(detail_renderer):
|
||||
try:
|
||||
detail_handled = bool(
|
||||
detail_renderer(
|
||||
selected_items,
|
||||
ctx=ctx,
|
||||
stage_is_last=True,
|
||||
source_command=str(source_command or ""),
|
||||
table_type=table_type,
|
||||
table_metadata=table_meta,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("%s detail fallback failed during selection: %s", key, exc)
|
||||
return True
|
||||
if detail_handled:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _maybe_expand_plugin_selection(
|
||||
selected_items: List[Any],
|
||||
@@ -2180,10 +2208,12 @@ class PipelineExecutor:
|
||||
filtered = expanded
|
||||
|
||||
if PipelineExecutor._maybe_run_class_selector(
|
||||
ctx,
|
||||
config,
|
||||
filtered,
|
||||
stage_is_last=(not stages)):
|
||||
ctx,
|
||||
config,
|
||||
filtered,
|
||||
stage_is_last=(not stages),
|
||||
source_command=source_cmd,
|
||||
prefer_detail_fallback=bool(prefer_row_action and not stages and len(selection_indices) == 1)):
|
||||
return False, None
|
||||
|
||||
from SYS.pipe_object import coerce_to_pipe_object
|
||||
@@ -2204,7 +2234,7 @@ class PipelineExecutor:
|
||||
except Exception:
|
||||
logger.exception("Failed to record Applied @N selection log step (pipeline_session=%r)", getattr(pipeline_session, 'worker_id', None))
|
||||
|
||||
# Auto-insert downloader stages for provider tables.
|
||||
# Auto-insert downloader stages for plugin tables.
|
||||
try:
|
||||
current_table = ctx.get_current_stage_table()
|
||||
if current_table is None and hasattr(ctx, "get_display_table"):
|
||||
@@ -2360,7 +2390,7 @@ class PipelineExecutor:
|
||||
|
||||
# Multi-selection fallback: if any selected row declares a
|
||||
# download-file action, insert a generic download-file stage.
|
||||
# This keeps provider-specific behavior in provider metadata.
|
||||
# This keeps plugin-specific behavior in plugin metadata.
|
||||
if (not inserted_provider_download) and len(selection_indices) > 1:
|
||||
try:
|
||||
has_download_row_action = False
|
||||
|
||||
@@ -68,10 +68,6 @@ def get_plugin_schema(plugin_name: str) -> List[ConfigField]:
|
||||
return _call_schema(plugin_class, f"plugin '{plugin_name}'")
|
||||
|
||||
|
||||
def get_provider_schema(provider_name: str) -> List[ConfigField]:
|
||||
return get_plugin_schema(provider_name)
|
||||
|
||||
|
||||
def get_tool_schema(tool_name: str) -> List[ConfigField]:
|
||||
tool_name = str(tool_name or "").strip()
|
||||
if not tool_name:
|
||||
@@ -149,10 +145,6 @@ def build_default_plugin_config(plugin_name: str) -> Dict[str, Any]:
|
||||
return config
|
||||
|
||||
|
||||
def build_default_provider_config(provider_name: str) -> Dict[str, Any]:
|
||||
return build_default_plugin_config(provider_name)
|
||||
|
||||
|
||||
def build_default_tool_config(tool_name: str) -> Dict[str, Any]:
|
||||
config: Dict[str, Any] = {}
|
||||
for field in get_tool_schema(tool_name):
|
||||
@@ -215,10 +207,6 @@ def get_configurable_plugin_types() -> List[str]:
|
||||
return sorted(set(options))
|
||||
|
||||
|
||||
def get_configurable_provider_types() -> List[str]:
|
||||
return get_configurable_plugin_types()
|
||||
|
||||
|
||||
def get_configurable_tool_types() -> List[str]:
|
||||
options: List[str] = []
|
||||
try:
|
||||
|
||||
+59
-12
@@ -724,7 +724,7 @@ class Table:
|
||||
"""Table type (e.g., 'youtube', 'soulseek') for context-aware selection logic."""
|
||||
|
||||
self.table_metadata: Dict[str, Any] = {}
|
||||
"""Optional provider/table metadata (e.g., provider name, view)."""
|
||||
"""Optional plugin/table metadata (e.g., plugin name, view)."""
|
||||
|
||||
self.value_case: str = "preserve"
|
||||
"""Display-only value casing: 'lower', 'upper', or 'preserve' (default)."""
|
||||
@@ -754,12 +754,12 @@ class Table:
|
||||
return self
|
||||
|
||||
def set_table_metadata(self, metadata: Optional[Dict[str, Any]]) -> "Table":
|
||||
"""Attach provider/table metadata for downstream selection logic."""
|
||||
"""Attach plugin/table metadata for downstream selection logic."""
|
||||
self.table_metadata = dict(metadata or {})
|
||||
return self
|
||||
|
||||
def get_table_metadata(self) -> Dict[str, Any]:
|
||||
"""Return attached provider/table metadata (copy to avoid mutation)."""
|
||||
"""Return attached plugin/table metadata (copy to avoid mutation)."""
|
||||
try:
|
||||
return dict(self.table_metadata)
|
||||
except Exception:
|
||||
@@ -2223,6 +2223,34 @@ def extract_item_metadata(item: Any) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
out = {}
|
||||
|
||||
def _merge_columns(columns_value: Any) -> None:
|
||||
if not isinstance(columns_value, (list, tuple)):
|
||||
return
|
||||
for column in columns_value:
|
||||
label = None
|
||||
value = None
|
||||
if isinstance(column, (list, tuple)) and len(column) >= 2:
|
||||
label, value = column[0], column[1]
|
||||
elif isinstance(column, dict):
|
||||
label = column.get("name") or column.get("label") or column.get("key")
|
||||
value = column.get("value")
|
||||
else:
|
||||
label = getattr(column, "name", None)
|
||||
value = getattr(column, "value", None)
|
||||
|
||||
label_text = str(label or "").strip()
|
||||
if not label_text or value is None:
|
||||
continue
|
||||
|
||||
value_text = str(value).strip()
|
||||
if not value_text:
|
||||
continue
|
||||
|
||||
normalized = label_text.lower()
|
||||
if any(str(existing or "").strip().lower() == normalized for existing in out):
|
||||
continue
|
||||
out[label_text] = value_text
|
||||
|
||||
# Handle ResultModel specifically for better detail display
|
||||
if ResultModel is not None and isinstance(item, ResultModel):
|
||||
@@ -2231,6 +2259,7 @@ def extract_item_metadata(item: Any) -> Dict[str, Any]:
|
||||
if item.ext: out["Ext"] = item.ext
|
||||
if item.size_bytes: out["Size"] = format_mb(item.size_bytes)
|
||||
if item.source: out["Store"] = item.source
|
||||
_merge_columns(getattr(item, "columns", None))
|
||||
|
||||
# Merge internal metadata dict
|
||||
if item.metadata:
|
||||
@@ -2256,6 +2285,7 @@ def extract_item_metadata(item: Any) -> Dict[str, Any]:
|
||||
# Fallback to existing extraction logic for legacy objects/dicts
|
||||
# Convert once and reuse throughout to avoid repeated _as_dict() calls
|
||||
data = _as_dict(item) or {}
|
||||
_merge_columns(data.get("columns"))
|
||||
|
||||
# Use existing extractors from match-standard result table columns
|
||||
title = extract_title_value(item)
|
||||
@@ -2350,12 +2380,14 @@ class ItemDetailView(Table):
|
||||
item_metadata: Optional[Dict[str, Any]] = None,
|
||||
detail_title: Optional[str] = None,
|
||||
exclude_tags: bool = False,
|
||||
detail_order: Optional[List[str]] = None,
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(title, **kwargs)
|
||||
self.item_metadata = item_metadata or {}
|
||||
self.detail_title = detail_title
|
||||
self.exclude_tags = exclude_tags
|
||||
self.detail_order = [str(value) for value in (detail_order or []) if str(value or "").strip()]
|
||||
|
||||
def to_rich(self):
|
||||
"""Render the item details panel above the standard results table."""
|
||||
@@ -2406,8 +2438,26 @@ class ItemDetailView(Table):
|
||||
|
||||
return Group(*renderables)
|
||||
|
||||
# Canonical display order for metadata
|
||||
order = ["Title", "Hash", "Store", "Path", "Ext", "Size", "Duration", "Url", "Relations"]
|
||||
def _has_renderable_value(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
return bool(text and text.lower() not in {"<null>", "null", "none"})
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return any(_has_renderable_value(item) for item in value)
|
||||
return True
|
||||
|
||||
# Canonical display order for metadata; plugin-specific detail views can
|
||||
# prepend a preferred order without needing to reimplement rendering.
|
||||
order: List[str] = []
|
||||
seen_order: set[str] = set()
|
||||
for key in list(self.detail_order or []) + ["Title", "Hash", "Store", "Path", "Ext", "Size", "Duration", "Url", "Relations"]:
|
||||
normalized = str(key or "").strip().lower()
|
||||
if not normalized or normalized in seen_order:
|
||||
continue
|
||||
seen_order.add(normalized)
|
||||
order.append(str(key))
|
||||
|
||||
has_details = False
|
||||
# Add ordered items first
|
||||
@@ -2431,19 +2481,16 @@ class ItemDetailView(Table):
|
||||
else:
|
||||
val = "\n".join([f"[dim]→[/dim] {r}" for r in val])
|
||||
|
||||
if val is not None and val != "":
|
||||
if _has_renderable_value(val):
|
||||
details_table.add_row(f"{key}:", str(val))
|
||||
has_details = True
|
||||
elif key in ["Url", "Relations", "Ext"]:
|
||||
# Show <null> for these important identifier fields if blank
|
||||
details_table.add_row(f"{key}:", "[dim]<null>[/dim]")
|
||||
has_details = True
|
||||
|
||||
# Add any remaining metadata not in the canonical list
|
||||
ordered_keys = {x.lower() for x in order}
|
||||
for k, v in self.item_metadata.items():
|
||||
k_norm = k.lower()
|
||||
if k_norm not in [x.lower() for x in order] and v and k_norm not in ["tags", "tag"]:
|
||||
label = k.capitalize() if len(k) > 1 else k.upper()
|
||||
if k_norm not in ordered_keys and _has_renderable_value(v) and k_norm not in ["tags", "tag"]:
|
||||
label = str(k or "")
|
||||
details_table.add_row(f"{label}:", str(v))
|
||||
has_details = True
|
||||
|
||||
|
||||
+12
-12
@@ -77,26 +77,26 @@ def capture_rich_output(*, stdout: TextIO, stderr: TextIO) -> Iterator[None]:
|
||||
_STDERR_CONSOLE = previous_stderr
|
||||
|
||||
|
||||
def show_provider_config_panel(
|
||||
provider_names: str | List[str],
|
||||
def show_plugin_config_panel(
|
||||
plugin_names: str | List[str],
|
||||
) -> None:
|
||||
"""Show a Rich panel explaining how to configure providers."""
|
||||
"""Show a Rich panel explaining how to configure plugins."""
|
||||
from rich.table import Table as RichTable
|
||||
from rich.console import Group
|
||||
|
||||
if isinstance(provider_names, str):
|
||||
providers = [p.strip() for p in provider_names.split(",")]
|
||||
if isinstance(plugin_names, str):
|
||||
plugins = [p.strip() for p in plugin_names.split(",")]
|
||||
else:
|
||||
providers = provider_names
|
||||
plugins = plugin_names
|
||||
|
||||
table = RichTable.grid(padding=(0, 1))
|
||||
table.add_column(style="bold red")
|
||||
|
||||
for provider in providers:
|
||||
table.add_row(f" • {provider}")
|
||||
for plugin in plugins:
|
||||
table.add_row(f" • {plugin}")
|
||||
|
||||
group = Group(
|
||||
Text("The following providers are not configured and cannot be used:\n"),
|
||||
Text("The following plugins are not configured and cannot be used:\n"),
|
||||
table,
|
||||
Text.from_markup("\nTo configure them, run the command with [bold cyan].config[/bold cyan] or use the [bold green]TUI[/bold green] config menu.")
|
||||
)
|
||||
@@ -147,17 +147,17 @@ def show_store_config_panel(
|
||||
stdout_console().print(panel)
|
||||
|
||||
|
||||
def show_available_providers_panel(provider_names: List[str]) -> None:
|
||||
def show_available_plugins_panel(plugin_names: List[str]) -> None:
|
||||
"""Show a Rich panel listing available/configured plugins."""
|
||||
from rich.columns import Columns
|
||||
from rich.console import Group
|
||||
|
||||
if not provider_names:
|
||||
if not plugin_names:
|
||||
return
|
||||
|
||||
# Use Columns to display them efficiently in the panel
|
||||
cols = Columns(
|
||||
[f"[bold green] \u2713 [/bold green]{p}" for p in sorted(provider_names)],
|
||||
[f"[bold green] \u2713 [/bold green]{p}" for p in sorted(plugin_names)],
|
||||
equal=True,
|
||||
column_first=True,
|
||||
expand=True
|
||||
|
||||
Reference in New Issue
Block a user