update refactoring and add new features
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
"""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 <dest>` 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
|
||||
Reference in New Issue
Block a user