dfdf
This commit is contained in:
290
result_table.py
290
result_table.py
@@ -12,11 +12,18 @@ Features:
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from typing import Any, Dict, List, Optional, Callable, Set
|
||||
from pathlib import Path
|
||||
import json
|
||||
import shutil
|
||||
|
||||
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
|
||||
|
||||
# Optional Textual imports - graceful fallback if not available
|
||||
try:
|
||||
from textual.widgets import Tree
|
||||
@@ -26,7 +33,7 @@ except ImportError:
|
||||
|
||||
|
||||
def _sanitize_cell_text(value: Any) -> str:
|
||||
"""Coerce to a single-line, tab-free string suitable for ASCII tables."""
|
||||
"""Coerce to a single-line, tab-free string suitable for terminal display."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
@@ -136,10 +143,15 @@ class ResultRow:
|
||||
|
||||
def add_column(self, name: str, value: Any) -> None:
|
||||
"""Add a column to this row."""
|
||||
# Normalize column header names.
|
||||
normalized_name = str(name or "").strip()
|
||||
if normalized_name.lower() == "name":
|
||||
normalized_name = "Title"
|
||||
|
||||
str_value = _sanitize_cell_text(value)
|
||||
|
||||
# Normalize extension columns globally and cap to 5 characters
|
||||
if str(name).strip().lower() == "ext":
|
||||
if normalized_name.lower() == "ext":
|
||||
str_value = str_value.strip().lstrip(".")
|
||||
for idx, ch in enumerate(str_value):
|
||||
if not ch.isalnum():
|
||||
@@ -147,7 +159,7 @@ class ResultRow:
|
||||
break
|
||||
str_value = str_value[:5]
|
||||
|
||||
self.columns.append(ResultColumn(name, str_value))
|
||||
self.columns.append(ResultColumn(normalized_name, str_value))
|
||||
|
||||
def get_column(self, name: str) -> Optional[str]:
|
||||
"""Get column value by name."""
|
||||
@@ -195,6 +207,30 @@ class ResultTable:
|
||||
preserve_order: When True, skip automatic sorting so row order matches source
|
||||
"""
|
||||
self.title = title
|
||||
try:
|
||||
import pipeline as ctx
|
||||
|
||||
cmdlet_name = ""
|
||||
try:
|
||||
cmdlet_name = ctx.get_current_cmdlet_name("") if hasattr(ctx, "get_current_cmdlet_name") else ""
|
||||
except Exception:
|
||||
cmdlet_name = ""
|
||||
|
||||
stage_text = ""
|
||||
try:
|
||||
stage_text = ctx.get_current_stage_text("") if hasattr(ctx, "get_current_stage_text") else ""
|
||||
except Exception:
|
||||
stage_text = ""
|
||||
|
||||
if cmdlet_name and stage_text:
|
||||
normalized_cmd = str(cmdlet_name).replace("_", "-").strip().lower()
|
||||
normalized_title = str(self.title or "").strip().lower()
|
||||
normalized_stage = str(stage_text).strip()
|
||||
if normalized_stage and normalized_stage.lower().startswith(normalized_cmd):
|
||||
if (not normalized_title) or normalized_title.replace("_", "-").startswith(normalized_cmd):
|
||||
self.title = normalized_stage
|
||||
except Exception:
|
||||
pass
|
||||
self.title_width = title_width
|
||||
self.max_columns = max_columns if max_columns is not None else 5 # Default 5 for cleaner display
|
||||
self.rows: List[ResultRow] = []
|
||||
@@ -214,6 +250,26 @@ class ResultTable:
|
||||
self.table: Optional[str] = None
|
||||
"""Table type (e.g., 'youtube', 'soulseek') for context-aware selection logic."""
|
||||
|
||||
self.value_case: str = "lower"
|
||||
"""Display-only value casing: 'lower' (default), 'upper', or 'preserve'."""
|
||||
|
||||
def set_value_case(self, value_case: str) -> "ResultTable":
|
||||
"""Configure display-only casing for rendered cell values."""
|
||||
case = str(value_case or "").strip().lower()
|
||||
if case not in {"lower", "upper", "preserve"}:
|
||||
case = "lower"
|
||||
self.value_case = case
|
||||
return self
|
||||
|
||||
def _apply_value_case(self, text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if self.value_case == "upper":
|
||||
return text.upper()
|
||||
if self.value_case == "preserve":
|
||||
return text
|
||||
return text.lower()
|
||||
|
||||
def set_table(self, table: str) -> "ResultTable":
|
||||
"""Set the table type for context-aware selection logic."""
|
||||
self.table = table
|
||||
@@ -459,7 +515,7 @@ class ResultTable:
|
||||
|
||||
# Size (for files)
|
||||
if hasattr(result, 'size_bytes') and result.size_bytes:
|
||||
row.add_column("Size (Mb)", _format_size(result.size_bytes, integer_only=True))
|
||||
row.add_column("Size", _format_size(result.size_bytes, integer_only=False))
|
||||
|
||||
# Annotations
|
||||
if hasattr(result, 'annotations') and result.annotations:
|
||||
@@ -505,9 +561,9 @@ class ResultTable:
|
||||
elif getattr(item, 'store', None):
|
||||
row.add_column("Storage", str(getattr(item, 'store')))
|
||||
|
||||
# Size (for files) - integer MB only
|
||||
# Size (for files)
|
||||
if hasattr(item, 'size_bytes') and item.size_bytes:
|
||||
row.add_column("Size (Mb)", _format_size(item.size_bytes, integer_only=True))
|
||||
row.add_column("Size", _format_size(item.size_bytes, integer_only=False))
|
||||
|
||||
def _add_tag_item(self, row: ResultRow, item: Any) -> None:
|
||||
"""Extract and add TagItem fields to row (compact tag display).
|
||||
@@ -575,9 +631,9 @@ class ResultTable:
|
||||
|
||||
Priority field groups (first match per group):
|
||||
- title | name | filename
|
||||
- ext
|
||||
- size | size_bytes
|
||||
- store | table | source
|
||||
- size | size_bytes
|
||||
- ext
|
||||
"""
|
||||
# Helper to determine if a field should be hidden from display
|
||||
def is_hidden_field(field_name: Any) -> bool:
|
||||
@@ -670,9 +726,9 @@ class ResultTable:
|
||||
# Explicitly set which columns to display in order
|
||||
priority_groups = [
|
||||
('title', ['title', 'name', 'filename']),
|
||||
('ext', ['ext']),
|
||||
('size', ['size', 'size_bytes']),
|
||||
('store', ['store', 'table', 'source']),
|
||||
('size', ['size', 'size_bytes']),
|
||||
('ext', ['ext']),
|
||||
]
|
||||
|
||||
# Add priority field groups first - use first match in each group
|
||||
@@ -681,9 +737,9 @@ class ResultTable:
|
||||
break
|
||||
for field in field_options:
|
||||
if field in visible_data and field not in added_fields:
|
||||
# Special handling for size fields - format as MB integer
|
||||
# Special handling for size fields - format with unit and decimals
|
||||
if field in ['size', 'size_bytes']:
|
||||
value_str = _format_size(visible_data[field], integer_only=True)
|
||||
value_str = _format_size(visible_data[field], integer_only=False)
|
||||
else:
|
||||
value_str = format_value(visible_data[field])
|
||||
|
||||
@@ -694,7 +750,7 @@ class ResultTable:
|
||||
if field in ['store', 'table', 'source']:
|
||||
col_name = "Store"
|
||||
elif field in ['size', 'size_bytes']:
|
||||
col_name = "Size (Mb)"
|
||||
col_name = "Size"
|
||||
elif field in ['title', 'name', 'filename']:
|
||||
col_name = "Title"
|
||||
else:
|
||||
@@ -727,115 +783,56 @@ class ResultTable:
|
||||
|
||||
row.add_column(key.replace('_', ' ').title(), value_str)
|
||||
|
||||
def format_plain(self) -> str:
|
||||
"""Format table as plain text with aligned columns and row numbers.
|
||||
|
||||
Returns:
|
||||
Formatted table string
|
||||
"""
|
||||
def to_rich(self):
|
||||
"""Return a Rich renderable representing this table."""
|
||||
if not self.rows:
|
||||
return "No results"
|
||||
empty = Text("No results")
|
||||
return Panel(empty, title=self.title) if self.title else empty
|
||||
|
||||
# Cap rendering to terminal width so long tables don't hard-wrap and
|
||||
# visually break the border/shape.
|
||||
term_width = shutil.get_terminal_size(fallback=(120, 24)).columns
|
||||
if not term_width or term_width <= 0:
|
||||
term_width = 120
|
||||
|
||||
# Calculate column widths
|
||||
col_widths: Dict[str, int] = {}
|
||||
col_names: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
for row in self.rows:
|
||||
for col in row.columns:
|
||||
col_name = col.name
|
||||
value_width = len(col.value)
|
||||
if col_name.lower() == "ext":
|
||||
value_width = min(value_width, 5)
|
||||
col_widths[col_name] = max(
|
||||
col_widths.get(col_name, 0),
|
||||
len(col.name),
|
||||
value_width
|
||||
)
|
||||
|
||||
# Calculate row number column width (skip if no-choice)
|
||||
num_width = 0 if self.no_choice else len(str(len(self.rows))) + 1
|
||||
if col.name not in seen:
|
||||
seen.add(col.name)
|
||||
col_names.append(col.name)
|
||||
|
||||
# Preserve column order
|
||||
column_names = list(col_widths.keys())
|
||||
table = RichTable(
|
||||
show_header=True,
|
||||
header_style="bold",
|
||||
box=SIMPLE,
|
||||
expand=True,
|
||||
show_lines=False,
|
||||
)
|
||||
|
||||
def capped_width(name: str) -> int:
|
||||
if not self.no_choice:
|
||||
table.add_column("#", justify="right", no_wrap=True)
|
||||
|
||||
# Render headers in uppercase, but keep original column keys for lookup.
|
||||
header_by_key: Dict[str, str] = {name: str(name).upper() for name in col_names}
|
||||
|
||||
for name in col_names:
|
||||
header = header_by_key.get(name, str(name).upper())
|
||||
if name.lower() == "ext":
|
||||
cap = 5
|
||||
table.add_column(header, no_wrap=True)
|
||||
else:
|
||||
# Single-column tables (e.g., get-tag) can use more horizontal space,
|
||||
# but still must stay within the terminal to avoid hard wrapping.
|
||||
if len(column_names) == 1:
|
||||
# Keep room for side walls and optional row-number column.
|
||||
cap = max(30, min(240, term_width - 6))
|
||||
else:
|
||||
cap = 90
|
||||
return min(col_widths[name], cap)
|
||||
table.add_column(header)
|
||||
|
||||
widths = ([] if self.no_choice else [num_width]) + [capped_width(name) for name in column_names]
|
||||
base_inner_width = sum(widths) + (len(widths) - 1) * 3 # account for " | " separators
|
||||
for row_idx, row in enumerate(self.rows, 1):
|
||||
cells: List[str] = []
|
||||
if not self.no_choice:
|
||||
cells.append(str(row_idx))
|
||||
for name in col_names:
|
||||
val = row.get_column(name) or ""
|
||||
cells.append(self._apply_value_case(_sanitize_cell_text(val)))
|
||||
table.add_row(*cells)
|
||||
|
||||
# Compute final table width (with side walls) to accommodate headers/titles
|
||||
table_width = base_inner_width + 2 # side walls
|
||||
if self.title:
|
||||
table_width = max(table_width, len(self.title) + 2)
|
||||
if self.header_lines:
|
||||
table_width = max(table_width, max(len(line) for line in self.header_lines) + 2)
|
||||
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
|
||||
return Panel(renderable, title=self.title) if self.title else renderable
|
||||
|
||||
# Ensure final render doesn't exceed terminal width (minus 1 safety column).
|
||||
safe_term_width = max(20, term_width - 1)
|
||||
table_width = min(table_width, safe_term_width)
|
||||
|
||||
def wrap(text: str) -> str:
|
||||
"""Wrap content with side walls and pad to table width."""
|
||||
if len(text) > table_width - 2:
|
||||
text = text[: table_width - 5] + "..." # keep walls intact
|
||||
return "|" + text.ljust(table_width - 2) + "|"
|
||||
|
||||
lines = []
|
||||
|
||||
# Title block
|
||||
if self.title:
|
||||
lines.append("|" + "=" * (table_width - 2) + "|")
|
||||
safe_title = _sanitize_cell_text(self.title)
|
||||
lines.append(wrap(safe_title.ljust(table_width - 2)))
|
||||
lines.append("|" + "=" * (table_width - 2) + "|")
|
||||
|
||||
# Optional header metadata lines
|
||||
for meta in self.header_lines:
|
||||
safe_meta = _sanitize_cell_text(meta)
|
||||
lines.append(wrap(safe_meta))
|
||||
|
||||
# Add header with # column
|
||||
header_parts = [] if self.no_choice else ["#".ljust(num_width)]
|
||||
separator_parts = [] if self.no_choice else ["-" * num_width]
|
||||
for col_name in column_names:
|
||||
width = capped_width(col_name)
|
||||
header_parts.append(col_name.ljust(width))
|
||||
separator_parts.append("-" * width)
|
||||
|
||||
lines.append(wrap(" | ".join(header_parts)))
|
||||
lines.append(wrap("-+-".join(separator_parts)))
|
||||
|
||||
# Add rows with row numbers
|
||||
for row_num, row in enumerate(self.rows, 1):
|
||||
row_parts = [] if self.no_choice else [str(row_num).ljust(num_width)]
|
||||
for col_name in column_names:
|
||||
width = capped_width(col_name)
|
||||
col_value = row.get_column(col_name) or ""
|
||||
col_value = _sanitize_cell_text(col_value)
|
||||
if len(col_value) > width:
|
||||
col_value = col_value[: width - 3] + "..."
|
||||
row_parts.append(col_value.ljust(width))
|
||||
lines.append(wrap(" | ".join(row_parts)))
|
||||
|
||||
# Bottom border to close the rectangle
|
||||
lines.append("|" + "=" * (table_width - 2) + "|")
|
||||
|
||||
return "\n".join(lines)
|
||||
return table
|
||||
|
||||
def format_compact(self) -> str:
|
||||
"""Format table in compact form (one line per row).
|
||||
@@ -880,8 +877,16 @@ class ResultTable:
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation (plain text format)."""
|
||||
return self.format_plain()
|
||||
"""String representation.
|
||||
|
||||
Rich is the primary rendering path. This keeps accidental `print(table)`
|
||||
usage from emitting ASCII box-drawn tables.
|
||||
"""
|
||||
label = self.title or "ResultTable"
|
||||
return f"{label} ({len(self.rows)} rows)"
|
||||
|
||||
def __rich__(self):
|
||||
return self.to_rich()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Developer representation."""
|
||||
@@ -921,20 +926,24 @@ class ResultTable:
|
||||
If accept_args=True: Dict with "indices" and "args" keys, or None if cancelled
|
||||
"""
|
||||
if self.no_choice:
|
||||
print(f"\n{self}")
|
||||
print("Selection is disabled for this table.")
|
||||
from rich_display import stdout_console
|
||||
|
||||
stdout_console().print(self)
|
||||
stdout_console().print(Panel(Text("Selection is disabled for this table.")))
|
||||
return None
|
||||
|
||||
# Display the table
|
||||
print(f"\n{self}")
|
||||
from rich_display import stdout_console
|
||||
|
||||
stdout_console().print(self)
|
||||
|
||||
# Get user input
|
||||
while True:
|
||||
try:
|
||||
if accept_args:
|
||||
choice = input(f"\n{prompt} (e.g., '5' or '2 -storage hydrus' or 'q' to quit): ").strip()
|
||||
choice = Prompt.ask(f"{prompt} (e.g., '5' or '2 -storage hydrus' or 'q' to quit)").strip()
|
||||
else:
|
||||
choice = input(f"\n{prompt} (e.g., '5' or '3-5' or '1,3,5' or 'q' to quit): ").strip()
|
||||
choice = Prompt.ask(f"{prompt} (e.g., '5' or '3-5' or '1,3,5' or 'q' to quit)").strip()
|
||||
|
||||
if choice.lower() == 'q':
|
||||
return None
|
||||
@@ -944,18 +953,18 @@ class ResultTable:
|
||||
result = self._parse_selection_with_args(choice)
|
||||
if result is not None:
|
||||
return result
|
||||
print(f"Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus').")
|
||||
stdout_console().print(Panel(Text("Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus').")))
|
||||
else:
|
||||
# Parse just the selection
|
||||
selected_indices = self._parse_selection(choice)
|
||||
if selected_indices is not None:
|
||||
return selected_indices
|
||||
print(f"Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit.")
|
||||
stdout_console().print(Panel(Text("Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit.")))
|
||||
except (ValueError, EOFError):
|
||||
if accept_args:
|
||||
print(f"Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus').")
|
||||
stdout_console().print(Panel(Text("Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus').")))
|
||||
else:
|
||||
print(f"Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit.")
|
||||
stdout_console().print(Panel(Text("Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit.")))
|
||||
|
||||
def _parse_selection(self, selection_str: str) -> Optional[List[int]]:
|
||||
"""Parse user selection string into list of 0-based indices.
|
||||
@@ -1317,10 +1326,10 @@ def _format_size(size: Any, integer_only: bool = False) -> str:
|
||||
|
||||
Args:
|
||||
size: Size in bytes or already formatted string
|
||||
integer_only: If True, show MB as integer only (e.g., "250 MB" not "250.5 MB")
|
||||
integer_only: If True, show MB as an integer (e.g., "250 MB")
|
||||
|
||||
Returns:
|
||||
Formatted size string (e.g., "250 MB", "1.5 MB" or "250 MB" if integer_only=True)
|
||||
Formatted size string with units (e.g., "3.53 MB", "0.57 MB", "1.2 GB")
|
||||
"""
|
||||
if isinstance(size, str):
|
||||
return size if size else ""
|
||||
@@ -1329,23 +1338,22 @@ def _format_size(size: Any, integer_only: bool = False) -> str:
|
||||
bytes_val = int(size)
|
||||
if bytes_val < 0:
|
||||
return ""
|
||||
|
||||
if integer_only:
|
||||
# For table display: always show as integer MB if >= 1MB
|
||||
mb_val = int(bytes_val / (1024 * 1024))
|
||||
if mb_val > 0:
|
||||
return str(mb_val)
|
||||
kb_val = int(bytes_val / 1024)
|
||||
if kb_val > 0:
|
||||
return str(kb_val)
|
||||
return str(bytes_val)
|
||||
|
||||
# Keep display consistent with the CLI expectation: show MB with unit
|
||||
# (including values under 1 MB as fractional MB), and show GB for very
|
||||
# large sizes.
|
||||
if bytes_val >= 1024**3:
|
||||
value = bytes_val / (1024**3)
|
||||
unit = "GB"
|
||||
else:
|
||||
# For descriptions: show with one decimal place
|
||||
for unit, divisor in [("GB", 1024**3), ("MB", 1024**2), ("KB", 1024)]:
|
||||
if bytes_val >= divisor:
|
||||
return f"{bytes_val / divisor:.1f} {unit}"
|
||||
|
||||
return f"{bytes_val} B"
|
||||
value = bytes_val / (1024**2)
|
||||
unit = "MB"
|
||||
|
||||
if integer_only:
|
||||
return f"{int(round(value))} {unit}"
|
||||
|
||||
num = f"{value:.2f}".rstrip("0").rstrip(".")
|
||||
return f"{num} {unit}"
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user