From 77cab1bd2763531d435f9d9ff023ef17cc55befa Mon Sep 17 00:00:00 2001 From: Nose Date: Sun, 3 May 2026 17:29:32 -0700 Subject: [PATCH] updating and refactoring codebase for improved performance and maintainability --- API/base.py | 61 +++++++++++++--- ProviderCore/registry.py | 36 ++++++++++ SYS/cli_parsing.py | 49 ++++++++++--- SYS/logger.py | 35 +++++---- SYS/models.py | 136 ++++++++++++++++++++--------------- SYS/pipeline.py | 84 +++++++++++++--------- SYS/result_table.py | 115 +++++++++++++++++------------ SYS/rich_display.py | 51 +++++++------ SYS/utils.py | 26 +++++-- cmdlet/add_file.py | 104 +++++++++++++++++++++------ cmdlet/download_file.py | 4 +- cmdlet/get_tag.py | 59 ++++++++------- cmdlet/merge_file.py | 66 ++++++++--------- cmdnat/_status_shared.py | 7 +- plugins/matrix/__init__.py | 4 ++ scripts/run_tests_serial.ps1 | 6 ++ tool/__init__.py | 41 ++++++++--- 17 files changed, 590 insertions(+), 294 deletions(-) create mode 100644 scripts/run_tests_serial.ps1 diff --git a/API/base.py b/API/base.py index afe972c..02c23b7 100644 --- a/API/base.py +++ b/API/base.py @@ -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 diff --git a/ProviderCore/registry.py b/ProviderCore/registry.py index 3c2291d..0d896e3 100644 --- a/ProviderCore/registry.py +++ b/ProviderCore/registry.py @@ -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", ] diff --git a/SYS/cli_parsing.py b/SYS/cli_parsing.py index dac8d6f..2109b30 100644 --- a/SYS/cli_parsing.py +++ b/SYS/cli_parsing.py @@ -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( diff --git a/SYS/logger.py b/SYS/logger.py index 2653476..8d7aaa4 100644 --- a/SYS/logger.py +++ b/SYS/logger.py @@ -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"") @@ -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 \ No newline at end of file diff --git a/SYS/models.py b/SYS/models.py index e87b31a..600a1e7 100644 --- a/SYS/models.py +++ b/SYS/models.py @@ -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, diff --git a/SYS/pipeline.py b/SYS/pipeline.py index 2d5e385..93c7208 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -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, diff --git a/SYS/result_table.py b/SYS/result_table.py index fad1477..e1c4b48 100644 --- a/SYS/result_table.py +++ b/SYS/result_table.py @@ -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) diff --git a/SYS/rich_display.py b/SYS/rich_display.py index c3b20f8..dbf4ab6 100644 --- a/SYS/rich_display.py +++ b/SYS/rich_display.py @@ -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) diff --git a/SYS/utils.py b/SYS/utils.py index 7dfd6cb..3feb800 100644 --- a/SYS/utils.py +++ b/SYS/utils.py @@ -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", diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index 67205ee..6ce89a7 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -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, diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index c2e9a63..4373444 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -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 diff --git a/cmdlet/get_tag.py b/cmdlet/get_tag.py index cfcca32..e916ef7 100644 --- a/cmdlet/get_tag.py +++ b/cmdlet/get_tag.py @@ -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): diff --git a/cmdlet/merge_file.py b/cmdlet/merge_file.py index 5a086dc..7241a6a 100644 --- a/cmdlet/merge_file.py +++ b/cmdlet/merge_file.py @@ -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): diff --git a/cmdnat/_status_shared.py b/cmdnat/_status_shared.py index 43279a9..74a412d 100644 --- a/cmdnat/_status_shared.py +++ b/cmdnat/_status_shared.py @@ -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__})" diff --git a/plugins/matrix/__init__.py b/plugins/matrix/__init__.py index d4de26f..4d0ed05 100644 --- a/plugins/matrix/__init__.py +++ b/plugins/matrix/__init__.py @@ -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 diff --git a/scripts/run_tests_serial.ps1 b/scripts/run_tests_serial.ps1 new file mode 100644 index 0000000..35a9a09 --- /dev/null +++ b/scripts/run_tests_serial.ps1 @@ -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" +} diff --git a/tool/__init__.py b/tool/__init__.py index ddbda1e..bbd0336 100644 --- a/tool/__init__.py +++ b/tool/__init__.py @@ -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 (~1–2 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 +