updating and refactoring codebase for improved performance and maintainability

This commit is contained in:
2026-05-03 17:29:32 -07:00
parent b7d3dc5f2d
commit 77cab1bd27
17 changed files with 590 additions and 294 deletions
+53 -8
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from typing import Any, Dict, Optional
import threading
from .HTTP import HTTPClient
@@ -16,6 +17,43 @@ class API:
def __init__(self, base_url: str, timeout: float = 10.0) -> None:
self.base_url = str(base_url or "").rstrip("/")
self.timeout = float(timeout)
self._http_client: Optional[HTTPClient] = None
self._http_client_lock = threading.Lock()
def _get_http_client(self) -> HTTPClient:
"""Return a reusable opened HTTP client for this API instance."""
client = self._http_client
if client is not None and getattr(client, "_client", None) is not None:
return client
with self._http_client_lock:
client = self._http_client
if client is None:
client = HTTPClient(timeout=self.timeout)
self._http_client = client
if getattr(client, "_client", None) is None:
client.__enter__()
return client
def close(self) -> None:
client = self._http_client
if client is None:
return
with self._http_client_lock:
current = self._http_client
if current is None:
return
try:
current.__exit__(None, None, None)
except Exception:
pass
self._http_client = None
def __del__(self) -> None:
try:
self.close()
except Exception:
pass
def _get_json(
self,
@@ -25,10 +63,10 @@ class API:
) -> Dict[str, Any]:
url = f"{self.base_url}/{str(path or '').lstrip('/')}"
try:
with HTTPClient(timeout=self.timeout, headers=headers) as client:
response = client.get(url, params=params, allow_redirects=True)
response.raise_for_status()
return response.json()
client = self._get_http_client()
response = client.get(url, params=params, headers=headers, allow_redirects=True)
response.raise_for_status()
return response.json()
except Exception as exc:
raise ApiError(f"API request failed for {url}: {exc}") from exc
@@ -41,9 +79,16 @@ class API:
) -> Dict[str, Any]:
url = f"{self.base_url}/{str(path or '').lstrip('/')}"
try:
with HTTPClient(timeout=self.timeout, headers=headers) as client:
response = client.post(url, json=json_data, params=params, allow_redirects=True)
response.raise_for_status()
return response.json()
client = self._get_http_client()
response = client.request(
"POST",
url,
json=json_data,
params=params,
headers=headers,
follow_redirects=True,
)
response.raise_for_status()
return response.json()
except Exception as exc:
raise ApiError(f"API request failed for {url}: {exc}") from exc
+36
View File
@@ -27,6 +27,22 @@ from ProviderCore.base import Provider, SearchResult
_EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH")
# Plugin instance cache keyed by (name, config_fingerprint)
_plugin_instance_cache: Dict[Tuple[str, str], Optional[Provider]] = {}
_plugin_cache_lock = __import__("threading").Lock()
def _config_fingerprint(config: Optional[Dict[str, Any]]) -> str:
"""Create a stable fingerprint of config for caching purposes."""
if config is None:
return "none"
try:
import json
normalized = json.dumps(config, sort_keys=True, default=str)
return hashlib.md5(normalized.encode()).hexdigest()[:16]
except Exception:
return "unknown"
def _class_supports_method(
plugin_class: Type[Provider],
@@ -603,14 +619,26 @@ def get_plugin(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[P
debug(f"[plugin] Unknown plugin: {name}")
return None
# Check cache first
cache_key = (str(name).strip().lower(), _config_fingerprint(config))
with _plugin_cache_lock:
if cache_key in _plugin_instance_cache:
return _plugin_instance_cache[cache_key]
try:
plugin = info.plugin_class(config)
if not plugin.validate():
debug(f"[plugin] Plugin '{name}' is not available")
with _plugin_cache_lock:
_plugin_instance_cache[cache_key] = None
return None
with _plugin_cache_lock:
_plugin_instance_cache[cache_key] = plugin
return plugin
except Exception as exc:
debug(f"[plugin] Error initializing '{name}': {exc}")
with _plugin_cache_lock:
_plugin_instance_cache[cache_key] = None
return None
@@ -860,6 +888,13 @@ def resolve_inline_filters(
return filters
def clear_plugin_cache() -> None:
"""Clear the plugin instance cache. Useful for testing or config reloads."""
global _plugin_instance_cache
with _plugin_cache_lock:
_plugin_instance_cache.clear()
__all__ = [
"PluginInfo",
"Provider",
@@ -879,4 +914,5 @@ __all__ = [
"selection_auto_stage_for_table",
"plugin_inline_query_choices",
"is_known_plugin_name",
"clear_plugin_cache",
]
+38 -11
View File
@@ -10,18 +10,45 @@ import re
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from SYS.logger import debug
# Prompt-toolkit lexer types are optional at import time; fall back to lightweight
# stubs if prompt_toolkit is not available so imports remain safe for testing.
try:
from prompt_toolkit.document import Document
from prompt_toolkit.lexers import Lexer as _PTK_Lexer
except Exception: # pragma: no cover - optional dependency
Document = object # type: ignore
# Fallback to a simple object when prompt_toolkit is not available
_PTK_Lexer = object # type: ignore
# Prompt-toolkit lexer types are optional and expensive (~300ms). Use find_spec
# to detect availability without importing, then lazy-load on first use.
import importlib.util as _importlib_util
_PTK_AVAILABLE: bool = _importlib_util.find_spec("prompt_toolkit") is not None
# Expose a stable name used by the rest of the module
Lexer = _PTK_Lexer
_ptk_Document: Any = None
_ptk_Lexer: Any = None
def _get_ptk_Document() -> Any:
global _ptk_Document
if _ptk_Document is None:
if _PTK_AVAILABLE:
from prompt_toolkit.document import Document as _Doc
_ptk_Document = _Doc
else:
_ptk_Document = object
return _ptk_Document
def _get_ptk_Lexer() -> Any:
global _ptk_Lexer
if _ptk_Lexer is None:
if _PTK_AVAILABLE:
from prompt_toolkit.lexers import Lexer as _Lex
_ptk_Lexer = _Lex
else:
_ptk_Lexer = object
return _ptk_Lexer
# Stable aliases: these resolve lazily the first time they are accessed.
# Code that does `isinstance(x, Document)` or `class Foo(Lexer)` at class-body
# time needs the real object, so we keep module-level names that proxy to the
# lazy getters via __getattr__ on the module. Callers that reference
# Document/Lexer INSIDE functions will always get the real class.
# Populate the module-level names now so that class bodies below can inherit.
Document: Any = _get_ptk_Document()
Lexer: Any = _get_ptk_Lexer()
# Pre-compiled regexes for the lexer (avoid recompiling on every call)
TOKEN_PATTERN = re.compile(
+23 -12
View File
@@ -1,13 +1,21 @@
"""Unified logging utility for automatic file and function name tracking."""
import sys
import inspect
import logging
import threading
from pathlib import Path
from typing import Any, Optional, Sequence
from SYS.rich_display import console_for
# SYS.rich_display deferred: rich (~100ms) loaded lazily on first log output.
_rich_display_mod: Any = None
def _console_for(file):
global _rich_display_mod
if _rich_display_mod is None:
import SYS.rich_display as _m
_rich_display_mod = _m
return _rich_display_mod.console_for(file)
logger = logging.getLogger(__name__)
@@ -73,7 +81,8 @@ def _is_rich_renderable(value: Any) -> bool:
def _caller_location(depth: int = 1) -> tuple[str, str]:
frame = inspect.currentframe()
import inspect as _inspect
frame = _inspect.currentframe()
current = frame
try:
for _ in range(max(0, int(depth))):
@@ -160,7 +169,7 @@ def debug(*args, **kwargs) -> None:
if len(args) == 1 and _is_rich_renderable(args[0]):
renderable = args[0]
console_for(target_file).print(renderable)
_console_for(target_file).print(renderable)
file_name, func_name = _caller_location(depth=1)
caller_name = f"{file_name}.{func_name}" if file_name and func_name else ""
_debug_db_log(caller_name=caller_name, message=f"<rich:{type(renderable).__name__}>")
@@ -200,7 +209,8 @@ def debug_inspect(
# Compute caller prefix (same as log()).
prefix = None
frame = inspect.currentframe()
import inspect as _inspect
frame = _inspect.currentframe()
if frame is not None and frame.f_back is not None:
caller_frame = frame.f_back
try:
@@ -215,7 +225,7 @@ def debug_inspect(
# Render.
from rich import inspect as rich_inspect
console = console_for(file)
console = _console_for(file)
# If the caller provides a title, treat it as authoritative.
# Only fall back to the automatic [file.func] prefix when no title is supplied.
effective_title = title
@@ -266,12 +276,13 @@ def log(*args, **kwargs) -> None:
add_prefix = _DEBUG_ENABLED
# Get the calling frame
frame = inspect.currentframe()
import inspect as _inspect
frame = _inspect.currentframe()
if frame is None:
file = kwargs.pop("file", sys.stdout)
sep = kwargs.pop("sep", " ")
end = kwargs.pop("end", "\n")
console_for(file).print(*args, sep=sep, end=end)
_console_for(file).print(*args, sep=sep, end=end)
return
caller_frame = frame.f_back
@@ -279,7 +290,7 @@ def log(*args, **kwargs) -> None:
file = kwargs.pop("file", sys.stdout)
sep = kwargs.pop("sep", " ")
end = kwargs.pop("end", "\n")
console_for(file).print(*args, sep=sep, end=end)
_console_for(file).print(*args, sep=sep, end=end)
return
try:
@@ -302,9 +313,9 @@ def log(*args, **kwargs) -> None:
end = kwargs.pop("end", "\n")
if add_prefix:
prefix = f"[{file_name}.{func_name}]"
console_for(file).print(prefix, *args, sep=sep, end=end)
_console_for(file).print(prefix, *args, sep=sep, end=end)
else:
console_for(file).print(*args, sep=sep, end=end)
_console_for(file).print(*args, sep=sep, end=end)
# Log to database if available
if _DB_LOGGER:
@@ -316,4 +327,4 @@ def log(*args, **kwargs) -> None:
pass
finally:
del frame
del caller_frame
del caller_frame
+80 -56
View File
@@ -1,5 +1,7 @@
"""Data models for the pipeline."""
from __future__ import annotations
import datetime
import hashlib
import inspect
@@ -16,23 +18,9 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Protocol, TextIO
from rich.console import Console
from rich.console import ConsoleOptions
from rich.console import Group
from rich.live import Live
from rich.panel import Panel
from rich.progress import (
BarColumn,
DownloadColumn,
Progress,
SpinnerColumn,
TaskID,
TaskProgressColumn,
TextColumn,
TimeRemainingColumn,
TimeElapsedColumn,
TransferSpeedColumn,
)
# rich imports are deferred to avoid ~100ms startup cost.
# Classes in this module that use rich types (ProgressBar, PipelineLiveProgress)
# import them lazily inside their method bodies at first use.
@dataclass(slots=True)
@@ -440,6 +428,42 @@ def _sanitise_for_json(
return repr(value)
def _import_rich() -> Any:
"""Lazy-load rich types used by ProgressBar and PipelineLiveProgress."""
import rich.console as _rc
import rich.live as _rl
import rich.panel as _rp
import rich.progress as _rprog
# Return a namespace-like object with the types we need
class _Rich:
Console = _rc.Console
ConsoleOptions = _rc.ConsoleOptions
Group = _rc.Group
Live = _rl.Live
Panel = _rp.Panel
Progress = _rprog.Progress
BarColumn = _rprog.BarColumn
DownloadColumn = _rprog.DownloadColumn
SpinnerColumn = _rprog.SpinnerColumn
TaskID = _rprog.TaskID
TaskProgressColumn = _rprog.TaskProgressColumn
TextColumn = _rprog.TextColumn
TimeRemainingColumn = _rprog.TimeRemainingColumn
TimeElapsedColumn = _rprog.TimeElapsedColumn
TransferSpeedColumn = _rprog.TransferSpeedColumn
return _Rich
_rich: Any = None # cached after first call
def _r() -> Any:
global _rich
if _rich is None:
_rich = _import_rich()
return _rich
class ProgressBar:
"""Rich progress helper for byte-based transfers.
@@ -521,16 +545,16 @@ class ProgressBar:
console = stderr_console()
except Exception:
logger.exception("Failed to acquire shared stderr Console from SYS.rich_display; using fallback Console")
console = Console(file=stream)
console = _r().Console(file=stream)
else:
console = Console(file=stream)
progress = Progress(
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
DownloadColumn(),
TransferSpeedColumn(),
TimeRemainingColumn(),
console = _r().Console(file=stream)
progress = _r().Progress(
_r().TextColumn("[progress.description]{task.description}"),
_r().BarColumn(),
_r().TaskProgressColumn(),
_r().DownloadColumn(),
_r().TransferSpeedColumn(),
_r().TimeRemainingColumn(),
console=console,
transient=True,
)
@@ -867,7 +891,7 @@ class PipelineLiveProgress:
overall = self._overall
if pipe_progress is None or transfers is None or overall is None:
# Not started (or stopped).
yield Panel("", title="Pipeline", expand=False)
yield _r().Panel("", title="Pipeline", expand=False)
return
body_parts: List[Any] = [pipe_progress]
@@ -875,8 +899,8 @@ class PipelineLiveProgress:
body_parts.append(status)
body_parts.append(transfers)
yield Group(
Panel(Group(*body_parts),
yield _r().Group(
_r().Panel(_r().Group(*body_parts),
title=self._title_text(),
expand=False),
overall
@@ -895,8 +919,8 @@ class PipelineLiveProgress:
if status is not None and self._status_tasks:
body_parts.append(status)
body_parts.append(transfers)
return Group(
Panel(Group(*body_parts),
return _r().Group(
_r().Panel(_r().Group(*body_parts),
title=self._title_text(),
expand=False),
overall
@@ -911,55 +935,55 @@ class PipelineLiveProgress:
# IMPORTANT: use the shared stderr Console instance so that any
# `stderr_console().print(...)` calls from inside cmdlets (e.g. preflight
# tables/prompts in download-file) cooperate with Rich Live rendering.
# If we create a separate Console(file=sys.stderr), output will fight for
# If we create a separate _r().Console(file=sys.stderr), output will fight for
# terminal cursor control and appear "blocked"/truncated.
from SYS.rich_display import stderr_console
self._console = stderr_console()
# Persistent per-pipe bars.
self._pipe_progress = Progress(
TextColumn("{task.description}"),
TimeElapsedColumn(),
BarColumn(),
TaskProgressColumn(),
self._pipe_progress = _r().Progress(
_r().TextColumn("{task.description}"),
_r().TimeElapsedColumn(),
_r().BarColumn(),
_r().TaskProgressColumn(),
console=self._console,
transient=False,
)
# Transient, per-item spinner for the currently-active subtask.
self._subtasks = Progress(
TextColumn(" "),
SpinnerColumn("simpleDots"),
TextColumn("{task.description}"),
self._subtasks = _r().Progress(
_r().TextColumn(" "),
_r().SpinnerColumn("simpleDots"),
_r().TextColumn("{task.description}"),
console=self._console,
transient=False,
)
# Status line below the pipe bars. Kept simple (no extra bar) so it
# doesn't visually offset the main pipe bar columns.
self._status = Progress(
TextColumn(" [bold]└─ {task.description}[/bold]"),
self._status = _r().Progress(
_r().TextColumn(" [bold]└─ {task.description}[/bold]"),
console=self._console,
transient=False,
)
# Byte-based transfer bars (download/upload) integrated into the Live view.
self._transfers = Progress(
TextColumn(" {task.description}"),
BarColumn(),
TaskProgressColumn(),
DownloadColumn(),
TransferSpeedColumn(),
TimeRemainingColumn(),
self._transfers = _r().Progress(
_r().TextColumn(" {task.description}"),
_r().BarColumn(),
_r().TaskProgressColumn(),
_r().DownloadColumn(),
_r().TransferSpeedColumn(),
_r().TimeRemainingColumn(),
console=self._console,
transient=False,
)
self._overall = Progress(
TimeElapsedColumn(),
BarColumn(),
TextColumn("{task.description}"),
self._overall = _r().Progress(
_r().TimeElapsedColumn(),
_r().BarColumn(),
_r().TextColumn("{task.description}"),
console=self._console,
transient=False,
)
@@ -982,7 +1006,7 @@ class PipelineLiveProgress:
len(self._pipe_labels)),
)
self._live = Live(
self._live = _r().Live(
self,
console=self._console,
refresh_per_second=10,
@@ -1011,7 +1035,7 @@ class PipelineLiveProgress:
# Not initialized yet; start fresh.
self.start()
return
self._live = Live(
self._live = _r().Live(
self,
console=self._console,
refresh_per_second=10,
+49 -35
View File
@@ -13,11 +13,41 @@ from SYS.models import PipelineStageContext
from SYS.logger import log, debug, debug_panel, is_debug_enabled
import logging
logger = logging.getLogger(__name__)
from SYS.worker import WorkerManagerRegistry, WorkerStages
from SYS.cli_parsing import SelectionSyntax, SelectionFilterSyntax
from SYS.rich_display import stdout_console
from SYS.background_notifier import ensure_background_notifier
from SYS.result_table import Table
# SYS.worker deferred: ffmpeg+attr+rich (~260ms) loaded lazily on first pipeline run.
_worker_mod: Any = None
# SYS.cli_parsing deferred: prompt_toolkit (~300ms) loaded lazily on first selection.
_cli_parsing_mod: Any = None
def _worker():
global _worker_mod
if _worker_mod is None:
import SYS.worker as _m
_worker_mod = _m
return _worker_mod
def _cli_parsing():
global _cli_parsing_mod
if _cli_parsing_mod is None:
import SYS.cli_parsing as _m
_cli_parsing_mod = _m
return _cli_parsing_mod
# SYS.rich_display deferred: rich (~100ms) loaded lazily on first console output.
# SYS.background_notifier deferred: rich/attr/ffmpeg loaded lazily on first notifier use.
# SYS.result_table deferred: textual (~140ms) loaded lazily on first Table use.
_result_table_mod: Any = None
def _result_table():
global _result_table_mod
if _result_table_mod is None:
from SYS.result_table import Table as _T
_result_table_mod = _T
return _result_table_mod
import re
from datetime import datetime
from SYS.cmdlet_catalog import import_cmd_module
@@ -680,12 +710,14 @@ def set_last_result_table(
"""
state = _get_pipeline_state()
# Push current table to history before replacing
# Push current table to history before replacing.
# No .copy() needed: last_result_items is about to be replaced by reference,
# not mutated in place, so the old list reference is safe to keep in history.
if state.last_result_table is not None:
state.result_table_history.append(
(
state.last_result_table,
state.last_result_items.copy(),
state.last_result_items,
state.last_result_subject,
)
)
@@ -724,26 +756,6 @@ def set_last_result_table(
logger.exception("Failed to sort result_table and reorder items")
if (
result_table is not None
and hasattr(result_table, "sort_by_title")
and not getattr(result_table, "preserve_order", False)
):
try:
result_table.sort_by_title()
# Re-order items list to match the sorted table
if state.display_items and hasattr(result_table, "rows"):
sorted_items: List[Any] = []
for row in result_table.rows:
src_idx = getattr(row, "source_index", None)
if isinstance(src_idx, int) and 0 <= src_idx < len(state.display_items):
sorted_items.append(state.display_items[src_idx])
if len(sorted_items) == len(result_table.rows):
state.display_items = sorted_items
except Exception:
logger.exception("Failed to sort overlay result_table and reorder items")
def set_last_result_table_overlay(
result_table: Optional[Any],
items: Optional[List[Any]] = None,
@@ -1423,7 +1435,7 @@ class PipelineExecutor:
new_first_stage: List[str] = []
for token in first_stage_tokens:
if token.startswith("@"): # selection
selection = SelectionSyntax.parse(token)
selection = _cli_parsing().SelectionSyntax.parse(token)
if selection is not None:
first_stage_selection_indices = sorted(
[i - 1 for i in selection]
@@ -1848,6 +1860,7 @@ class PipelineExecutor:
}
if output_fn:
kwargs["output"] = output_fn
from SYS.background_notifier import ensure_background_notifier
ensure_background_notifier(worker_manager, **kwargs)
except Exception:
logger.exception("Failed to enable background notifier for session_worker_ids=%r", session_worker_ids)
@@ -2633,9 +2646,9 @@ class PipelineExecutor:
)
piped_result: Any = None
worker_manager = WorkerManagerRegistry.ensure(config)
worker_manager = _worker().WorkerManagerRegistry.ensure(config)
pipeline_text = " | ".join(" ".join(stage) for stage in stages)
pipeline_session = WorkerStages.begin_pipeline(
pipeline_session = _worker().WorkerStages.begin_pipeline(
worker_manager,
pipeline_text=pipeline_text,
config=config
@@ -2790,8 +2803,8 @@ class PipelineExecutor:
if cmd_name.startswith("@"): # selection stage
selection_token = raw_stage_name
selection = SelectionSyntax.parse(selection_token)
filter_spec = SelectionFilterSyntax.parse(selection_token)
selection = _cli_parsing().SelectionSyntax.parse(selection_token)
filter_spec = _cli_parsing().SelectionFilterSyntax.parse(selection_token)
is_select_all = selection_token.strip() == "@*"
if selection is None and filter_spec is None and not is_select_all:
print(f"Invalid selection: {selection_token}\n")
@@ -2849,7 +2862,7 @@ class PipelineExecutor:
elif filter_spec is not None:
selected_indices = [
i for i, item in enumerate(items_list)
if SelectionFilterSyntax.matches(item, filter_spec)
if _cli_parsing().SelectionFilterSyntax.matches(item, filter_spec)
]
else:
selected_indices = sorted(
@@ -2894,7 +2907,7 @@ class PipelineExecutor:
if base_table is not None and hasattr(base_table, "copy_with_title"):
new_table = base_table.copy_with_title(getattr(base_table, "title", "") or "Results")
else:
new_table = Table(getattr(base_table, "title", "") if base_table is not None else "Results")
new_table = _result_table()(getattr(base_table, "title", "") if base_table is not None else "Results")
try:
if base_table is not None and getattr(base_table, "table", None):
@@ -2918,6 +2931,7 @@ class PipelineExecutor:
logger.exception("Failed to set last_result_table_overlay for filter selection")
try:
from SYS.rich_display import stdout_console
stdout_console().print()
stdout_console().print(new_table)
except Exception:
@@ -3118,7 +3132,7 @@ class PipelineExecutor:
pipe_idx = pipe_index_by_stage.get(stage_index)
overlay_table: Any | None = None
session = WorkerStages.begin_stage(
session = _worker().WorkerStages.begin_stage(
worker_manager,
cmd_name=cmd_name,
stage_tokens=stage_tokens,
+71 -44
View File
@@ -18,20 +18,47 @@ from pathlib import Path
import json
import re
from rich.box import SIMPLE
from rich.console import Group
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table as RichTable
from rich.text import Text
# rich imports are deferred to avoid ~100ms startup cost.
# All rich types are only needed inside method bodies, so we lazily import on first use.
_rich_mod: Any = None
# Optional Textual imports - graceful fallback if not available
try:
from textual.widgets import Tree
TEXTUAL_AVAILABLE = True
except ImportError:
TEXTUAL_AVAILABLE = False
def _rich():
global _rich_mod
if _rich_mod is None:
import types as _types
_m = _types.SimpleNamespace()
from rich.box import SIMPLE as _SIMPLE
from rich.console import Group as _Group
from rich.panel import Panel as _Panel
from rich.prompt import Prompt as _Prompt
from rich.table import Table as _RichTable
from rich.text import Text as _Text
_m.SIMPLE = _SIMPLE
_m.Group = _Group
_m.Panel = _Panel
_m.Prompt = _Prompt
_m.RichTable = _RichTable
_m.Text = _Text
_rich_mod = _m
return _rich_mod
# Optional Textual imports - lazily loaded to avoid pulling in ~300ms of textual
# at import time when the TUI is not being used.
import importlib.util as _importlib_util
TEXTUAL_AVAILABLE: bool = _importlib_util.find_spec("textual") is not None
# Tree is populated lazily on first call to build_metadata_tree().
_textual_Tree: Any = None
def _get_textual_Tree() -> Any:
global _textual_Tree
if _textual_Tree is None:
from textual.widgets import Tree as _Tree
_textual_Tree = _Tree
return _textual_Tree
# Import ResultModel from the API for typing; avoid runtime redefinition issues
@@ -1591,11 +1618,11 @@ class Table:
panel_style = get_result_table_panel_style({"table_appearance": appearance_mode})
if not self.rows:
empty = Text("No results")
empty = _rich().Text("No results")
return (
Panel(
_rich().Panel(
empty,
title=Text(str(self.title), style=header_style),
title=_rich().Text(str(self.title), style=header_style),
border_style=border_style,
padding=(0, 0),
expand=False,
@@ -1613,7 +1640,7 @@ class Table:
seen.add(col.name)
col_names.append(col.name)
table = RichTable(
table = _rich().RichTable(
show_header=True,
header_style=header_style,
border_style=border_style,
@@ -1661,12 +1688,12 @@ class Table:
)
if self.title or self.header_lines:
header_bits = [Text(line) for line in (self.header_lines or [])]
renderable = Group(*header_bits, table) if header_bits else table
header_bits = [_rich().Text(line) for line in (self.header_lines or [])]
renderable = _rich().Group(*header_bits, table) if header_bits else table
return (
Panel(
_rich().Panel(
renderable,
title=Text(str(self.title), style=header_style),
title=_rich().Text(str(self.title), style=header_style),
border_style=border_style,
padding=(0, 0),
expand=False,
@@ -1777,7 +1804,7 @@ class Table:
from SYS.rich_display import stdout_console
stdout_console().print(self)
stdout_console().print(Panel(Text("Selection is disabled for this table.")))
stdout_console().print(_rich().Panel(_rich().Text("Selection is disabled for this table.")))
return None
# Display the table
@@ -1789,11 +1816,11 @@ class Table:
while True:
try:
if accept_args:
choice = Prompt.ask(
choice = _rich().Prompt.ask(
f"{prompt} (e.g., '5' or '2 -storage hydrus' or 'q' to quit)"
).strip()
else:
choice = Prompt.ask(
choice = _rich().Prompt.ask(
f"{prompt} (e.g., '5' or '3-5' or '1,3,5' or 'q' to quit)"
).strip()
@@ -1806,8 +1833,8 @@ class Table:
if result is not None:
return result
stdout_console().print(
Panel(
Text(
_rich().Panel(
_rich().Text(
"Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus')."
)
)
@@ -1818,8 +1845,8 @@ class Table:
if selected_indices is not None:
return selected_indices
stdout_console().print(
Panel(
Text(
_rich().Panel(
_rich().Text(
"Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit."
)
)
@@ -1827,16 +1854,16 @@ class Table:
except (ValueError, EOFError):
if accept_args:
stdout_console().print(
Panel(
Text(
_rich().Panel(
_rich().Text(
"Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus')."
)
)
)
else:
stdout_console().print(
Panel(
Text(
_rich().Panel(
_rich().Text(
"Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit."
)
)
@@ -2468,12 +2495,12 @@ class ItemDetailView(Table):
from rich.text import Text
# 1. Create Detail Grid (matching rich_display.py style)
details_table = RichTable.grid(expand=True, padding=(0, 2))
details_table = _rich().RichTable.grid(expand=True, padding=(0, 2))
details_table.add_column("Key", style="cyan", justify="right", width=15)
details_table.add_column("Value", style="white")
def _render_tag_text(tag_value: Any) -> Text:
tag_text = Text()
tag_text = _rich().Text()
tag_text.append("#", style="dim")
raw = str(tag_value or "")
@@ -2497,17 +2524,17 @@ class ItemDetailView(Table):
renderables.append(_render_tag_text(tag))
if freeform_tags:
freeform_grid = RichTable.grid(expand=True, padding=(0, 2))
freeform_grid = _rich().RichTable.grid(expand=True, padding=(0, 2))
for _ in range(3):
freeform_grid.add_column(ratio=1)
for row_values in _chunk_detail_tags(freeform_tags, 3):
cells = [_render_tag_text(tag) for tag in row_values]
while len(cells) < 3:
cells.append(Text(""))
cells.append(_rich().Text(""))
freeform_grid.add_row(*cells)
renderables.append(freeform_grid)
return Group(*renderables)
return _rich().Group(*renderables)
def _has_renderable_value(value: Any) -> bool:
if value is None:
@@ -2596,9 +2623,9 @@ class ItemDetailView(Table):
header_style = get_result_table_header_style()
border_style = get_result_table_border_style()
detail_title = str(self.detail_title or "Item Details").strip() or "Item Details"
elements.append(Panel(
elements.append(_rich().Panel(
details_table,
title=Text(detail_title, style=header_style),
title=_rich().Text(detail_title, style=header_style),
border_style=border_style,
padding=(1, 2)
))
@@ -2606,10 +2633,10 @@ class ItemDetailView(Table):
if results_renderable:
# If it's a Panel already (from super().to_rich() with title), use it directly
# but force the border style to the result-table standard for consistency
if isinstance(results_renderable, Panel):
if isinstance(results_renderable, _rich().Panel):
results_renderable.border_style = get_result_table_border_style()
if results_renderable.title:
results_renderable.title = Text(
results_renderable.title = _rich().Text(
str(results_renderable.title),
style=get_result_table_header_style(),
)
@@ -2622,13 +2649,13 @@ class ItemDetailView(Table):
display_title = original_title
# Add a bit of padding
results_group = Group(Text(""), results_renderable, Text(""))
results_group = _rich().Group(_rich().Text(""), results_renderable, _rich().Text(""))
elements.append(
Panel(
_rich().Panel(
results_group,
title=Text(str(display_title), style=get_result_table_header_style()),
title=_rich().Text(str(display_title), style=get_result_table_header_style()),
border_style=get_result_table_border_style(),
)
)
return Group(*elements)
return _rich().Group(*elements)
+30 -21
View File
@@ -13,42 +13,49 @@ import contextlib
import sys
from typing import Any, Iterator, TextIO, List, Dict, Optional, Tuple, cast
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from pathlib import Path
from SYS.utils import expand_path
# Configure Rich pretty-printing to avoid truncating long strings (hashes/paths).
# This is version-safe: older Rich versions may not support the max_* arguments.
try:
from rich.pretty import install as _pretty_install
# rich imports are deferred to first Console use to avoid ~100ms startup cost.
# They are loaded the first time any Console function is called.
try:
_pretty_install(max_string=100_000, max_length=100_000)
except TypeError:
_pretty_install()
except Exception:
from SYS.logger import logger
logger.exception("Failed to configure rich pretty-printing")
_STDOUT_CONSOLE = Console(file=sys.stdout)
_STDERR_CONSOLE = Console(file=sys.stderr)
_STDOUT_CONSOLE: Any = None
_STDERR_CONSOLE: Any = None
def stdout_console() -> Console:
def _ensure_consoles() -> None:
global _STDOUT_CONSOLE, _STDERR_CONSOLE
if _STDOUT_CONSOLE is None:
from rich.console import Console
from rich.pretty import install as _pretty_install
try:
_pretty_install(max_string=100_000, max_length=100_000)
except TypeError:
_pretty_install()
except Exception:
pass
_STDOUT_CONSOLE = Console(file=sys.stdout)
_STDERR_CONSOLE = Console(file=sys.stderr)
def stdout_console() -> Any:
_ensure_consoles()
return _STDOUT_CONSOLE
def stderr_console() -> Console:
def stderr_console() -> Any:
_ensure_consoles()
return _STDERR_CONSOLE
def console_for(file: TextIO | None) -> Console:
def console_for(file: Any) -> Any:
if file is None or file is sys.stdout:
_ensure_consoles()
return _STDOUT_CONSOLE
if file is sys.stderr:
_ensure_consoles()
return _STDERR_CONSOLE
from rich.console import Console
return Console(file=file)
@@ -57,7 +64,7 @@ def rprint(renderable: Any = "", *, file: TextIO | None = None) -> None:
@contextlib.contextmanager
def capture_rich_output(*, stdout: TextIO, stderr: TextIO) -> Iterator[None]:
def capture_rich_output(*, stdout: Any, stderr: Any) -> Iterator[None]:
"""Temporarily redirect Rich output helpers to provided streams.
Note: `stdout_console()` / `stderr_console()` use global Console instances,
@@ -65,9 +72,11 @@ def capture_rich_output(*, stdout: TextIO, stderr: TextIO) -> Iterator[None]:
"""
global _STDOUT_CONSOLE, _STDERR_CONSOLE
_ensure_consoles()
previous_stdout = _STDOUT_CONSOLE
previous_stderr = _STDERR_CONSOLE
from rich.console import Console
try:
_STDOUT_CONSOLE = Console(file=stdout)
_STDERR_CONSOLE = Console(file=stderr)
+19 -7
View File
@@ -4,13 +4,7 @@ from __future__ import annotations
import json
import hashlib
import subprocess
import shutil
try:
import ffmpeg # type: ignore
except Exception:
ffmpeg = None # type: ignore
import os
import base64
import logging
@@ -23,6 +17,22 @@ from urllib.parse import urlparse
from SYS.utils_constant import mime_maps
_ffmpeg_mod: Any = None
_ffmpeg_checked = False
def _get_ffmpeg():
"""Lazily return the ffmpeg module, or None if unavailable."""
global _ffmpeg_mod, _ffmpeg_checked
if not _ffmpeg_checked:
try:
import ffmpeg as _f # type: ignore
_ffmpeg_mod = _f
except Exception:
_ffmpeg_mod = None
_ffmpeg_checked = True
return _ffmpeg_mod
try:
import cbor2
except ImportError:
@@ -191,6 +201,7 @@ def ffprobe(file_path: str) -> dict:
probe = None
# Try python ffmpeg module first
ffmpeg = _get_ffmpeg()
if ffmpeg is not None:
try:
probe = ffmpeg.probe(file_path)
@@ -203,7 +214,8 @@ def ffprobe(file_path: str) -> dict:
ffprobe_cmd = shutil.which("ffprobe")
if ffprobe_cmd:
try:
proc = subprocess.run(
import subprocess as _subprocess
proc = _subprocess.run(
[
ffprobe_cmd,
"-v",
+82 -22
View File
@@ -40,11 +40,58 @@ build_pipeline_preview = sh.build_pipeline_preview
get_field = sh.get_field
from SYS.utils import sha256_file, unique_path, sanitize_filename
from SYS.metadata import write_metadata
# Canonical supported filetypes for all stores/cmdlets
SUPPORTED_MEDIA_EXTENSIONS = ALL_SUPPORTED_EXTENSIONS
class _CommandDependencies:
"""Command-scope cache for Store and plugin instances to avoid repeated instantiation."""
def __init__(self, config: Dict[str, Any]) -> None:
self.config = config
self._store: Optional[Store] = None
self._plugins: Dict[str, Any] = {}
def get_store(self) -> Optional[Store]:
"""Lazily initialize and return the command-scope Store instance."""
if self._store is None:
try:
self._store = Store(self.config)
except Exception:
self._store = None
return self._store
def get_plugin(self, name: str) -> Optional[Any]:
"""Cached plugin lookup by name."""
from ProviderCore.registry import get_plugin
norm_name = str(name or "").strip().lower()
if not norm_name:
return None
if norm_name in self._plugins:
return self._plugins[norm_name]
plugin = get_plugin(norm_name, self.config)
self._plugins[norm_name] = plugin
return plugin
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
norm_name = str(name or "").strip().lower()
if not norm_name:
return None
cache_key = f"{norm_name}#{capability}"
if cache_key in self._plugins:
return self._plugins[cache_key]
plugin = get_plugin_with_capability(norm_name, capability, self.config)
self._plugins[cache_key] = plugin
return plugin
DEBUG_PIPE_NOTE_PREVIEW_LENGTH = 256
# Protocol schemes that identify a remote resource / not a local file path.
@@ -220,11 +267,9 @@ class Add_File(Cmdlet):
parsed = parse_cmdlet_args(args, self)
progress = PipelineProgress(ctx)
# Initialize Store for backend resolution
try:
storage_registry = Store(config)
except Exception:
storage_registry = None
# Initialize command-scope dependency context (caches Store/plugins)
deps = _CommandDependencies(config)
storage_registry = deps.get_store()
path_arg = parsed.get("path")
location = parsed.get("store")
@@ -348,7 +393,7 @@ class Add_File(Cmdlet):
is_storage_backend_location = False
if location:
try:
store_for_lookup = storage_registry or Store(config)
store_for_lookup = storage_registry or deps.get_store()
is_storage_backend_location = Add_File._resolve_backend_by_name(store_for_lookup, str(location)) is not None
except Exception:
is_storage_backend_location = False
@@ -368,6 +413,7 @@ class Add_File(Cmdlet):
plugin_instance,
config,
store_instance=storage_registry,
deps=deps,
)
effective_storage_backend_name = plugin_storage_backend or (
@@ -629,10 +675,11 @@ class Add_File(Cmdlet):
config,
export_destination=(Path(location) if location and not is_storage_backend_location else None),
store_instance=storage_registry,
deps=deps,
)
if not media_path and plugin_name:
media_path, file_hash, temp_dir_to_cleanup = Add_File._download_piped_source(
pipe_obj, config, storage_registry
pipe_obj, config, storage_registry, deps=deps
)
if media_path:
try:
@@ -702,7 +749,7 @@ class Add_File(Cmdlet):
if location:
try:
store = storage_registry or Store(config)
store = storage_registry or deps.get_store()
resolved_backend = Add_File._resolve_backend_by_name(store, str(location))
if resolved_backend is not None:
code = self._handle_storage_backend(
@@ -833,7 +880,8 @@ class Add_File(Cmdlet):
Add_File._apply_pending_relationships(
pending_relationship_pairs,
config,
store_instance=storage_registry
store_instance=storage_registry,
deps=deps
)
except Exception:
pass
@@ -1063,6 +1111,7 @@ class Add_File(Cmdlet):
config: Dict[str,
Any],
store_instance: Optional[Store] = None,
deps: Optional[_CommandDependencies] = None,
) -> None:
"""Persist relationships to backends that support relationships.
@@ -1071,8 +1120,11 @@ class Add_File(Cmdlet):
if not pending:
return
if deps is None:
deps = _CommandDependencies(config)
try:
store = store_instance if store_instance is not None else Store(config)
store = store_instance if store_instance is not None else deps.get_store()
except Exception:
return
@@ -1343,6 +1395,7 @@ class Add_File(Cmdlet):
Any],
export_destination: Optional[Path] = None,
store_instance: Optional[Any] = None,
deps: Optional[_CommandDependencies] = None,
) -> Tuple[Optional[Path],
Optional[str],
Optional[Path]]:
@@ -1371,9 +1424,9 @@ class Add_File(Cmdlet):
if r_hash and r_store:
try:
store = store_instance
if not store:
store = Store(config)
if deps is None:
deps = _CommandDependencies(config)
store = store_instance or deps.get_store()
backend = Add_File._resolve_backend_by_name(store, r_store)
if backend is not None:
@@ -1441,6 +1494,7 @@ class Add_File(Cmdlet):
result,
pipe_obj,
config,
deps=deps,
)
if downloaded_path:
pipe_obj.path = str(downloaded_path)
@@ -1471,14 +1525,16 @@ class Add_File(Cmdlet):
config: Dict[str, Any],
*,
store_instance: Optional[Any] = None,
deps: Optional[_CommandDependencies] = None,
) -> Optional[str]:
plugin_key = Add_File._normalize_provider_key(plugin_name)
if not plugin_key:
return None
from ProviderCore.registry import get_plugin_with_capability
if deps is None:
deps = _CommandDependencies(config)
file_provider = get_plugin_with_capability(plugin_key, "upload", config)
file_provider = deps.get_plugin_with_capability(plugin_key, "upload")
if file_provider is None:
return None
@@ -1528,6 +1584,7 @@ class Add_File(Cmdlet):
result: Any,
pipe_obj: models.PipeObject,
config: Dict[str, Any],
deps: Optional[_CommandDependencies] = None,
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
plugin_key = None
for source in (
@@ -1544,9 +1601,10 @@ class Add_File(Cmdlet):
if not plugin_key:
return None, None, None
from ProviderCore.registry import get_plugin
if deps is None:
deps = _CommandDependencies(config)
plugin = get_plugin(plugin_key, config)
plugin = deps.get_plugin(plugin_key)
if plugin is None:
return None, None, None
@@ -1562,16 +1620,17 @@ class Add_File(Cmdlet):
pipe_obj: models.PipeObject,
config: Dict[str, Any],
store_instance: Optional[Any],
deps: Optional[_CommandDependencies] = None,
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
r_hash = str(getattr(pipe_obj, "hash", None) or getattr(pipe_obj, "file_hash", None) or "").strip()
r_store = str(getattr(pipe_obj, "store", None) or "").strip()
if not (r_hash and r_store):
return None, None, None
try:
store = store_instance or Store(config)
except Exception:
store = None
if deps is None:
deps = _CommandDependencies(config)
store = store_instance or deps.get_store()
backend = Add_File._resolve_backend_by_name(store, r_store) if store is not None else None
if backend is None:
return None, None, None
@@ -2244,6 +2303,7 @@ class Add_File(Cmdlet):
relationships = Add_File._get_relationships(result, pipe_obj)
try:
write_sidecar(target_path, tags, url, f_hash)
from SYS.metadata import write_metadata # lazy: avoids 1000+ module chain at startup
write_metadata(
target_path,
hash_value=f_hash,
+3 -1
View File
@@ -24,7 +24,8 @@ from SYS.pipeline_progress import PipelineProgress
from SYS.result_table import Table
from SYS.rich_display import stderr_console as get_stderr_console
from SYS import pipeline as pipeline_context
from SYS.metadata import normalize_urls as normalize_url_list
# SYS.metadata import deferred: normalize_urls loaded lazily at call site to avoid
# pulling in Cryptodome (~900ms) at module import time.
from SYS.selection_builder import (
extract_selection_fields,
extract_urls_from_selection_args,
@@ -1226,6 +1227,7 @@ class Download_File(Cmdlet):
and not a.startswith("-")
)
]
from SYS.metadata import normalize_urls as normalize_url_list # lazy: avoids Cryptodome at startup
raw_url = normalize_url_list(url_candidates)
quiet_mode = bool(config.get("_quiet_background_output")) if isinstance(config, dict) else False
+28 -31
View File
@@ -14,14 +14,20 @@ import sys
from SYS.logger import log, debug
from plugins.metadata_provider import (
get_default_subject_scrape_plugin,
get_metadata_plugin,
get_metadata_plugin_for_url,
list_metadata_plugins,
scrape_isbn_metadata,
scrape_openlibrary_metadata,
)
# plugins.metadata_provider 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
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
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
@@ -41,11 +47,6 @@ CmdletArg = sh.CmdletArg
SharedArgs = sh.SharedArgs
parse_cmdlet_args = sh.parse_cmdlet_args
try:
from SYS.metadata import extract_title
except ImportError:
extract_title = None
def _dedup_tags_preserve_order(tags: List[str]) -> List[str]:
"""Deduplicate tags case-insensitively while preserving order."""
@@ -210,7 +211,7 @@ def _extract_tag_value(tags_list: List[str], namespace: str) -> Optional[str]:
def _scrape_openlibrary_metadata(olid: str) -> List[str]:
try:
return list(scrape_openlibrary_metadata(olid))
return list(_mp().scrape_openlibrary_metadata(olid))
except Exception as e:
log(f"OpenLibrary scraping error: {e}", file=sys.stderr)
return []
@@ -218,7 +219,7 @@ def _scrape_openlibrary_metadata(olid: str) -> List[str]:
def _scrape_isbn_metadata(isbn: str) -> List[str]:
try:
return list(scrape_isbn_metadata(isbn))
return list(_mp().scrape_isbn_metadata(isbn))
except Exception as e:
log(f"ISBN scraping error: {e}", file=sys.stderr)
return []
@@ -400,7 +401,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
scrape_target = str(scrape_url or "").strip() if scrape_url is not None else ""
plugin = None
if scrape_target.startswith(("http://", "https://")):
plugin = get_metadata_plugin_for_url(scrape_target, config)
plugin = _mp().get_metadata_plugin_for_url(scrape_target, config)
if plugin is None:
log("No metadata plugin can scrape this URL", file=sys.stderr)
return 1
@@ -412,9 +413,9 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 0
if scrape_target:
plugin = get_metadata_plugin(scrape_target, config)
plugin = _mp().get_metadata_plugin(scrape_target, config)
else:
plugin = get_default_subject_scrape_plugin(config)
plugin = _mp().get_default_subject_scrape_plugin(config)
if plugin is None:
if scrape_target:
log(f"Unknown metadata plugin: {scrape_target}", file=sys.stderr)
@@ -749,7 +750,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
)
return 0
plugin_for_apply = get_metadata_plugin(str(result_provider), config)
plugin_for_apply = _mp().get_metadata_plugin(str(result_provider), config)
if plugin_for_apply is not None:
apply_tags = plugin_for_apply.filter_tags_for_store_apply(
[str(t) for t in result_tags if t is not None]
@@ -944,18 +945,14 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 0
_SCRAPE_CHOICES = []
try:
_SCRAPE_CHOICES = sorted(list_metadata_plugins().keys())
except Exception:
_SCRAPE_CHOICES = [
"itunes",
"openlibrary",
"googlebooks",
"google",
"musicbrainz",
"imdb",
]
_SCRAPE_CHOICES = [
"itunes",
"openlibrary",
"googlebooks",
"google",
"musicbrainz",
"imdb",
]
class Get_Tag(Cmdlet):
+33 -33
View File
@@ -40,49 +40,49 @@ except ImportError:
PdfWriter = None
PdfReader = None
try:
from SYS.metadata import (
read_tags_from_file,
merge_multiple_tag_lists,
)
# Stub fallbacks used before SYS.metadata is lazily imported (or if unavailable).
HAS_METADATA_API: bool = False
_metadata_loaded: bool = False
HAS_METADATA_API = True
except ImportError:
HAS_METADATA_API = False
def read_tags_from_file(file_path: Path) -> List[str]:
return []
def read_tags_from_file(file_path: Path) -> List[str]:
return []
def write_tags_to_file(
file_path: Path,
tags: List[str],
source_hashes: Optional[List[str]] = None,
url: Optional[List[str]] = None,
append: bool = False,
) -> bool:
return False
def dedup_tags_by_namespace(tags: List[str]) -> List[str]:
return tags
def merge_multiple_tag_lists(sources: List[List[str]],
strategy: str = "first") -> List[str]:
out: List[str] = []
seen: set[str] = set()
for src in sources:
for t in src or []:
s = str(t)
if s and s not in seen:
out.append(s)
seen.add(s)
return out
def merge_multiple_tag_lists(sources: List[List[str]],
strategy: str = "first") -> List[str]:
out: List[str] = []
seen: set[str] = set()
for src in sources:
for t in src or []:
s = str(t)
if s and s not in seen:
out.append(s)
seen.add(s)
return out
def write_metadata(*_args: Any, **_kwargs: Any) -> None:
return None
def _ensure_metadata_imports() -> None:
"""Lazily import SYS.metadata to avoid loading Cryptodome (~1s) at startup."""
global _metadata_loaded, HAS_METADATA_API, read_tags_from_file, merge_multiple_tag_lists
if _metadata_loaded:
return
_metadata_loaded = True
try:
from SYS.metadata import ( # type: ignore[assignment]
read_tags_from_file as _rtf,
merge_multiple_tag_lists as _mml,
)
read_tags_from_file = _rtf # type: ignore[assignment]
merge_multiple_tag_lists = _mml # type: ignore[assignment]
HAS_METADATA_API = True
except ImportError:
HAS_METADATA_API = False
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Merge multiple files into one."""
_ensure_metadata_imports()
# Parse help
if should_show_help(args):
+3 -4
View File
@@ -2,8 +2,6 @@ from __future__ import annotations
from typing import Any
import httpx
from SYS.result_table import Table
@@ -68,9 +66,10 @@ def ping_url(url: str, timeout: float = 3.0) -> tuple[bool, str]:
code = int(getattr(response, "status_code", 0) or 0)
ok = 200 <= code < 500
return ok, f"{url} (HTTP {code})"
except httpx.TimeoutException:
return False, f"{url} (timeout)"
except Exception as exc:
import httpx as _httpx
if isinstance(exc, _httpx.TimeoutException):
return False, f"{url} (timeout)"
return False, f"{url} ({type(exc).__name__})"
+4
View File
@@ -875,3 +875,7 @@ try:
except Exception:
# best-effort registration
pass
# Backward-compatible alias: tests and callers may import `plugins.matrix.cmdnat`.
from . import commands as cmdnat # noqa: E402
+6
View File
@@ -0,0 +1,6 @@
$tests = Get-ChildItem "$PSScriptRoot\..\tests\test_*.py" | Select-Object -ExpandProperty Name
foreach ($f in $tests) {
Write-Host "=== $f ===" -NoNewline
$out = & c:/Forgejo/Medios-Macina/.venv/Scripts/python.exe -m pytest "tests/$f" -q --tb=line 2>&1 | Select-Object -Last 2
Write-Host " $out"
}
+32 -9
View File
@@ -4,16 +4,39 @@ This package contains wrappers around external tools (e.g. yt-dlp) so cmdlets ca
common defaults (cookies, timeouts, format selectors) and users can override them via
`config.conf`.
"""
from __future__ import annotations
from .ytdlp import YtDlpTool, YtDlpDefaults
from .playwright import PlaywrightTool, PlaywrightDefaults
from .florencevision import FlorenceVisionTool, FlorenceVisionDefaults
# Lazy-loaded to avoid pulling in yt_dlp, playwright, and their heavy transitive
# dependencies (~12 s) at package import time. Each submodule is loaded only when
# a name from it is first accessed through this package namespace.
__all__ = [
"YtDlpTool",
"YtDlpDefaults",
"PlaywrightTool",
"PlaywrightDefaults",
"FlorenceVisionTool",
"FlorenceVisionDefaults",
"YtDlpTool",
"YtDlpDefaults",
"PlaywrightTool",
"PlaywrightDefaults",
"FlorenceVisionTool",
"FlorenceVisionDefaults",
]
_MODULE_ATTRS = {
"YtDlpTool": ".ytdlp",
"YtDlpDefaults": ".ytdlp",
"PlaywrightTool": ".playwright",
"PlaywrightDefaults": ".playwright",
"FlorenceVisionTool": ".florencevision",
"FlorenceVisionDefaults": ".florencevision",
}
def __getattr__(name: str) -> object:
submod = _MODULE_ATTRS.get(name)
if submod is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
mod = import_module(submod, package=__name__)
obj = getattr(mod, name)
# Cache on this module so subsequent accesses bypass __getattr__.
globals()[name] = obj
return obj