"""UI/display helpers for Rich output, saved-output panels, and result persistence. Contains utilities for: - Printing to stderr safely without disrupting Rich Live display - Rendering saved output panels when ``-path`` is used - Persisting items for ``@N`` selection across command boundaries - Formatting byte sizes for display - Moving emitted temp/PATH artifacts to user-specified destinations """ from __future__ import annotations import shutil import sys from contextlib import AbstractContextManager, nullcontext from pathlib import Path from typing import Any, Dict, List, Optional, Sequence from SYS.logger import log from SYS.result_publication import publish_result_table from SYS.result_table import Table from SYS.rich_display import stderr_console as get_stderr_console from ._pipeobject_utils import get_field, get_pipe_object_path __all__ = [ "_print_live_safe_stderr", "_print_saved_output_panel", "_apply_saved_path_update", "_unique_destination_path", "_extract_flag_value", "apply_output_path_from_pipeobjects", "display_and_persist_items", "fmt_bytes", ] def fmt_bytes(n: Optional[int]) -> str: """Format bytes as human-readable with 1 decimal place (MB/GB). Args: n: Number of bytes, or None Returns: Formatted string like "1.5 MB" or "2.0 GB", or "unknown" """ if n is None or n < 0: return "unknown" gb = n / (1024.0 * 1024.0 * 1024.0) if gb >= 1.0: return f"{gb:.1f} GB" mb = n / (1024.0 * 1024.0) return f"{mb:.1f} MB" def _extract_flag_value(args: Sequence[str], *flags: str) -> Optional[str]: """Return the value for the first matching flag in args. This is intentionally lightweight (no cmdlet spec required) so callers in CLI/pipeline can share the same behavior. """ if not args: return None want = {str(f).strip().lower() for f in flags if str(f).strip()} if not want: return None try: tokens = [str(a) for a in args] except Exception: tokens = list(args) # type: ignore[list-item] for i, tok in enumerate(tokens): low = str(tok).strip().lower() if low in want: if i + 1 >= len(tokens): return None nxt = str(tokens[i + 1]) if not nxt.strip(): return None if nxt.startswith("-"): return None return nxt return None def _unique_destination_path(dest: Path) -> Path: """Generate a non-colliding destination path by appending " (N)".""" try: if not dest.exists(): return dest except Exception: return dest parent = dest.parent stem = dest.stem suffix = dest.suffix for i in range(1, 10_000): candidate = parent / f"{stem} ({i}){suffix}" try: if not candidate.exists(): return candidate except Exception: return candidate return dest def _print_live_safe_stderr(message: str) -> None: """Print to stderr without breaking Rich Live progress output.""" try: from SYS.rich_display import stderr_console # type: ignore except Exception: return cm: AbstractContextManager[Any] | None = None try: from SYS import pipeline as _pipeline_ctx # type: ignore suspend = getattr(_pipeline_ctx, "suspend_live_progress", None) candidate = suspend() if callable(suspend) else None if isinstance(candidate, AbstractContextManager): cm = candidate elif candidate is not None and hasattr(candidate, "__enter__") and hasattr(candidate, "__exit__"): cm = candidate # type: ignore[arg-type] except Exception: cm = None if cm is None: cm = nullcontext() try: console = stderr_console() print_func = getattr(console, "print", None) except Exception: return if not callable(print_func): return try: with cm: print_func(str(message)) except Exception: return def _print_saved_output_panel(item: Any, final_path: Path) -> None: """When -path is used, print a Rich panel summarizing the saved output. Shows: Title, Location, Hash. Best-effort: reads existing fields first to avoid recomputing hashes. """ try: from rich.panel import Panel # type: ignore from rich.table import Table as RichTable # type: ignore from SYS.rich_display import stderr_console # type: ignore except Exception: return cm: AbstractContextManager[Any] | None = None try: from SYS import pipeline as _pipeline_ctx # type: ignore suspend = getattr(_pipeline_ctx, "suspend_live_progress", None) cm_candidate = suspend() if callable(suspend) else None if isinstance(cm_candidate, AbstractContextManager): cm = cm_candidate elif cm_candidate is not None and hasattr(cm_candidate, "__enter__") and hasattr(cm_candidate, "__exit__"): cm = cm_candidate # type: ignore[arg-type] except Exception: cm = None if cm is None: cm = nullcontext() try: location = str(final_path) except Exception: location = "" title = "" try: title = str(get_field(item, "title") or get_field(item, "name") or "").strip() except Exception: title = "" if not title: try: title = str(final_path.stem or final_path.name) except Exception: title = "" file_hash = "" try: file_hash = str(get_field(item, "hash") or get_field(item, "sha256") or "").strip() except Exception: file_hash = "" if not file_hash: try: from SYS.utils import sha256_file # type: ignore file_hash = str(sha256_file(final_path) or "").strip() except Exception: file_hash = "" grid = RichTable.grid(padding=(0, 1)) grid.add_column(justify="right", style="bold") grid.add_column() grid.add_row("Title", title or "(unknown)") grid.add_row("Location", location or "(unknown)") grid.add_row("Hash", file_hash or "(unknown)") try: console = stderr_console() print_func = getattr(console, "print", None) except Exception: return if not callable(print_func): return try: with cm: print_func(Panel(grid, title="Saved", expand=False)) except Exception: return def _apply_saved_path_update(item: Any, *, old_path: str, new_path: str) -> None: """Update a PipeObject-like item after its backing file has moved.""" old_str = str(old_path) new_str = str(new_path) if isinstance(item, dict): try: if str(item.get("path") or "") == old_str: item["path"] = new_str except Exception: pass try: if str(item.get("target") or "") == old_str: item["target"] = new_str except Exception: pass try: extra = item.get("extra") if isinstance(extra, dict): if str(extra.get("target") or "") == old_str: extra["target"] = new_str if str(extra.get("path") or "") == old_str: extra["path"] = new_str except Exception: pass return try: if getattr(item, "path", None) == old_str: setattr(item, "path", new_str) except Exception: pass try: extra = getattr(item, "extra", None) if isinstance(extra, dict): if str(extra.get("target") or "") == old_str: extra["target"] = new_str if str(extra.get("path") or "") == old_str: extra["path"] = new_str except Exception: pass def apply_output_path_from_pipeobjects( *, cmd_name: str, args: Sequence[str], emits: Sequence[Any], ) -> List[Any]: """If the user supplied `-path`, move emitted temp/PATH files there. This enables a dynamic pattern: - Any cmdlet can include `SharedArgs.PATH`. - If it emits a file-backed PipeObject (`path` exists on disk) and the item is a temp/PATH artifact, then `-path ` will save it to that location. Rules: - Only affects items whose `action` matches the current cmdlet. - Only affects items that look like local artifacts (`is_temp` True or `store` == PATH). - Updates the emitted object's `path` (and `target` when it points at the same file). """ dest_raw = _extract_flag_value(args, "-path", "--path") if not dest_raw: return list(emits or []) try: dest_str = str(dest_raw).strip() if "://" in dest_str: _print_live_safe_stderr( f"Ignoring -path value that looks like a URL: {dest_str}" ) return list(emits or []) except Exception: pass cmd_norm = str(cmd_name or "").replace("_", "-").strip().lower() if not cmd_norm: return list(emits or []) try: dest_hint_dir = str(dest_raw).endswith(("/", "\\")) except Exception: dest_hint_dir = False try: dest_path = Path(str(dest_raw)).expanduser() except Exception: return list(emits or []) items = list(emits or []) artifact_indices: List[int] = [] artifact_paths: List[Path] = [] for idx, item in enumerate(items): action = str(get_field(item, "action", "") or "").strip().lower() if not action.startswith("cmdlet:"): continue action_name = action.split(":", 1)[-1].strip().lower() if action_name != cmd_norm: continue store = str(get_field(item, "store", "") or "").strip().lower() is_temp = bool(get_field(item, "is_temp", False)) if not (is_temp or store == "path"): continue src_str = get_pipe_object_path(item) if not src_str: continue try: src = Path(str(src_str)).expanduser() except Exception: continue try: if not src.exists() or not src.is_file(): continue except Exception: continue artifact_indices.append(idx) artifact_paths.append(src) if not artifact_indices: return items if len(artifact_indices) > 1: if dest_path.suffix: dest_dir = dest_path.parent else: dest_dir = dest_path try: dest_dir.mkdir(parents=True, exist_ok=True) except Exception as exc: _print_live_safe_stderr( f"Failed to create destination directory: {dest_dir} ({exc})" ) return items for idx, src in zip(artifact_indices, artifact_paths): final = dest_dir / src.name final = _unique_destination_path(final) try: if src.resolve() == final.resolve(): _apply_saved_path_update( items[idx], old_path=str(src), new_path=str(final), ) _print_saved_output_panel(items[idx], final) continue except Exception: pass try: shutil.move(str(src), str(final)) except Exception as exc: _print_live_safe_stderr(f"Failed to save output to {final}: {exc}") continue _apply_saved_path_update(items[idx], old_path=str(src), new_path=str(final)) _print_saved_output_panel(items[idx], final) return items src = artifact_paths[0] idx = artifact_indices[0] final: Path try: if dest_hint_dir or (dest_path.exists() and dest_path.is_dir()): final = dest_path / src.name else: final = dest_path except Exception: final = dest_path try: final.parent.mkdir(parents=True, exist_ok=True) except Exception as exc: _print_live_safe_stderr( f"Failed to create destination directory: {final.parent} ({exc})" ) return items final = _unique_destination_path(final) try: if src.resolve() != final.resolve(): shutil.move(str(src), str(final)) except Exception as exc: _print_live_safe_stderr(f"Failed to save output to {final}: {exc}") return items _apply_saved_path_update(items[idx], old_path=str(src), new_path=str(final)) _print_saved_output_panel(items[idx], final) return items def display_and_persist_items( items: List[Any], title: str = "Result", subject: Optional[Any] = None, display_type: str = "item", table: Optional[Table] = None, ) -> None: """Display detail panels and persist items for @N selection. This helper function: 1. Renders individual detail panels for each item 2. Creates a result table with all items (or uses provided table) 3. Persists the table so @N selection works across command boundaries Args: items: List of items/dicts to display and make selectable title: Title for the result table (default: "Result") subject: Optional subject to associate with the results (usually first item or list) display_type: Type of display - "item" (default), "tag", or "custom" (no panels) table: Optional pre-built Table object (if None, creates new Table with title) """ if not items: return try: from SYS import pipeline as pipeline_context if table is None: table = Table(title=title) if display_type != "custom": try: from SYS.rich_display import render_item_details_panel panel_prefix = "Tag" if display_type == "tag" else "Item" for idx, item in enumerate(items, 1): try: render_item_details_panel(item, title=f"#{idx} {panel_prefix} Details") except Exception: pass except Exception: pass if not getattr(table, "_items_added", False): for item in items: table.add_result(item) setattr(table, "_rendered_by_cmdlet", True) publish_result_table(pipeline_context, table, items, subject=subject) except Exception: pass