Files
tarot/cli_renderers.py

363 lines
13 KiB
Python
Raw Normal View History

2025-12-05 03:41:16 -08:00
"""Shared renderers for typer-test output tables.
This module centralizes Rich table rendering so typer-test.py stays lean. It includes
specialized handling for planets plus generic list/dict/object renderers.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence
2025-12-07 20:52:47 -08:00
from rich import box
from rich.console import Console, Group
2025-12-05 03:41:16 -08:00
from rich.panel import Panel
from rich.table import Table
from tarot import Card
from utils.attributes import Color, Planet
class Renderer:
"""Render arbitrary Tarot objects using Rich tables."""
def __init__(self, console: Console) -> None:
self.console = console
self.planet_renderer = PlanetRenderer(self)
# Public helpers -----------------------------------------------------
def render_value(self, value: Any) -> None:
if isinstance(value, Planet):
self.planet_renderer.render(value)
return
if isinstance(value, Card):
self._render_card(value)
return
if isinstance(value, list) and value and isinstance(value[0], Card):
self.print_cards_table(value)
return
if hasattr(value, "upright") and hasattr(value, "reversed"):
self.print_kv_table(
"Meaning", {"upright": value.upright, "reversed": value.reversed}
)
return
if isinstance(value, dict):
self._render_dict(value)
return
if isinstance(value, list):
self._render_list(value)
return
if hasattr(value, "__dict__"):
self._render_object(value)
return
self.console.print(value)
def print_kv_table(self, title: str, data: Dict[str, Any]) -> None:
table = Table(title=title, show_header=False)
table.add_column("Field", style="bold")
table.add_column("Value")
for key, value in data.items():
table.add_row(self._humanize_key(str(key)), "" if value is None else str(value))
self.console.print(table)
def print_rows_table(self, title: str, columns: List[str], rows: List[List[Any]]) -> None:
table = Table(title=title, show_header=True, show_lines=True)
for col in columns:
table.add_column(col)
for row in rows:
str_row = ["" if v is None else str(v) for v in row]
table.add_row(*str_row)
self.console.print(table)
def print_cards_table(self, cards: List[Card]) -> None:
table = Table(title="Cards", show_lines=True)
table.add_column("#", justify="right")
table.add_column("Name")
table.add_column("Arcana")
table.add_column("Suit")
table.add_column("Pip/Court")
for card in cards:
suit = getattr(getattr(card, "suit", None), "name", "")
pip = str(getattr(card, "pip", "")) if getattr(card, "pip", None) else ""
court = getattr(card, "court_rank", "")
pip_display = court or pip
table.add_row(
str(getattr(card, "number", "")),
getattr(card, "name", ""),
getattr(card, "arcana", ""),
suit,
pip_display,
)
self.console.print(table)
# Internal renderers -------------------------------------------------
def _render_card(self, card: Card) -> None:
2025-12-07 20:52:47 -08:00
"""Render a card in a rectangular layout with top/middle/bottom bars."""
name = getattr(card, "name", "")
arcana = getattr(card, "arcana", "")
suit_obj = getattr(card, "suit", None)
suit = getattr(suit_obj, "name", "") if suit_obj is not None else ""
pip = getattr(card, "pip", None)
number = getattr(card, "number", "")
keywords = getattr(card, "keywords", None) or []
2025-12-05 03:41:16 -08:00
meaning = getattr(card, "meaning", None)
2025-12-07 20:52:47 -08:00
top = Table.grid(expand=True)
for align in ("left", "center", "right"):
top.add_column(justify=align)
top_left = suit or arcana
top_center = name
top_right = f"#{number}" if number not in (None, "") else ""
top.add_row(top_left, top_center, top_right)
body_lines = [f"Arcana: {arcana or '-'}"]
if suit:
body_lines.append(f"Suit: {suit}")
if pip:
body_lines.append(f"Pip: {pip}")
if getattr(card, "court_rank", ""):
body_lines.append(f"Court Rank: {card.court_rank}")
2025-12-05 03:41:16 -08:00
if keywords:
2025-12-07 20:52:47 -08:00
body_lines.append(f"Keywords: {', '.join(keywords)}")
2025-12-05 03:41:16 -08:00
guidance = getattr(card, "guidance", None)
if guidance:
2025-12-07 20:52:47 -08:00
body_lines.append(f"Guidance: {guidance}")
body = Panel("\n".join(body_lines), box=box.MINIMAL, padding=(1, 1))
bottom = Table(
show_header=False,
expand=True,
box=box.SIMPLE_HEAVY,
show_edge=True,
pad_edge=False,
)
bottom.add_column(justify="left")
bottom.add_column(justify="center")
bottom.add_column(justify="right")
bottom.add_row(
self._label_block("Upright", getattr(meaning, "upright", None)),
self._label_block("Reversed", getattr(meaning, "reversed", None)),
self._label_block("Keywords", ", ".join(keywords) if keywords else None),
2025-12-05 03:41:16 -08:00
)
2025-12-07 20:52:47 -08:00
card_panel = Panel(
Group(top, body, bottom),
box=box.SQUARE,
padding=0,
title=name,
expand=False,
)
self.console.print(card_panel)
2025-12-05 03:41:16 -08:00
def _render_object(self, obj: Any) -> None:
attrs = {k: v for k, v in vars(obj).items() if not k.startswith("_")}
display = {k: self._format_value(v) for k, v in attrs.items()}
title = getattr(obj, "name", None) or obj.__class__.__name__
self.print_kv_table(str(title), display)
def _render_dict(self, data: Dict[Any, Any], title: str = "Result") -> None:
if not data:
self.console.print(f"{title}: {{}}")
return
values = list(data.values())
if all(self._is_scalar(v) for v in values):
self.print_kv_table(title, data)
return
if all(isinstance(v, list) for v in values) and values and values[0]:
first_item = values[0][0]
if hasattr(first_item, "__dict__"):
self._render_dict_of_list_objects(data, title)
return
if all(hasattr(v, "__dict__") for v in values):
rows = []
sample_attrs = self._simple_attrs(values[0])
columns = ["Key"] + [self._humanize_key(k) for k in sample_attrs.keys()]
for key, obj in data.items():
attrs = self._simple_attrs(obj)
rows.append([key] + [attrs.get(k, "") for k in sample_attrs.keys()])
self.print_rows_table(title, columns, rows)
return
self.print_kv_table(title, {k: self._short_value(v) for k, v in data.items()})
def _render_list(self, items: List[Any], title: str = "Result") -> None:
if not items:
self.console.print(f"{title}: []")
return
first = items[0]
if isinstance(first, Card):
self.print_cards_table(items)
return
if hasattr(first, "__dict__"):
sample_attrs = self._simple_attrs(first)
columns = [self._humanize_key(k) for k in sample_attrs.keys()]
rows = []
for obj in items:
attrs = self._simple_attrs(obj)
rows.append([attrs.get(k, "") for k in sample_attrs.keys()])
self.print_rows_table(title, columns, rows)
return
if isinstance(first, dict):
keys = list(first.keys())
columns = [self._humanize_key(k) for k in keys]
rows = []
for obj in items:
rows.append([obj.get(k, "") for k in keys])
self.print_rows_table(title, columns, rows)
return
self.print_kv_table(title, {i: self._short_value(v) for i, v in enumerate(items)})
def _render_dict_of_list_objects(self, data: Dict[Any, List[Any]], title: str) -> None:
first_key = next(iter(data))
first_list = data[first_key]
if not first_list:
self.print_kv_table(title, {k: "[]" for k in data.keys()})
return
sample_attrs = self._simple_attrs(first_list[0])
columns = ["Key"] + [self._humanize_key(k) for k in sample_attrs.keys()]
rows: List[List[Any]] = []
for key, lst in data.items():
for obj in lst:
attrs = self._simple_attrs(obj)
rows.append([key] + [attrs.get(k, "") for k in sample_attrs.keys()])
self.print_rows_table(title, columns, rows)
# Value helpers ------------------------------------------------------
@staticmethod
def _simple_attrs(obj: Any) -> Dict[str, Any]:
return {k: v for k, v in vars(obj).items() if not k.startswith("_")}
@staticmethod
def _is_scalar(val: Any) -> bool:
return isinstance(val, (str, int, float, bool)) or val is None
def _format_value(self, val: Any, depth: int = 0) -> str:
if isinstance(val, list):
if not val:
return "[]"
lines = []
for item in val:
lines.append(" " * depth + "- " + self._format_value(item, depth + 1).lstrip())
return "\n".join(lines)
if isinstance(val, dict):
lines = []
for k, v in val.items():
lines.append(
" " * depth
+ f"{self._humanize_key(str(k))}: "
+ self._format_value(v, depth + 1)
)
return "\n".join(lines)
if hasattr(val, "__dict__"):
inner = {k: v for k, v in vars(val).items() if not k.startswith("_")}
if not inner:
return str(val)
parts = [
f"{self._humanize_key(str(k))}={self._short_value(v)}" for k, v in inner.items()
]
name = getattr(val, "name", val.__class__.__name__)
return f"{name}: " + "; ".join(parts)
return str(val)
def _short_value(self, val: Any) -> str:
if isinstance(val, Card):
return f"Card {getattr(val, 'number', '?')}: {getattr(val, 'name', '')}"
if isinstance(val, list):
if not val:
return "[]"
preview = ", ".join(self._short_value(v) for v in val[:4])
suffix = "..." if len(val) > 4 else ""
return f"[{preview}{suffix}]"
if isinstance(val, dict):
return f"dict({len(val)})"
return str(val)
@staticmethod
def _humanize_key(key: str) -> str:
return key.replace("_", " ")
2025-12-07 20:52:47 -08:00
@staticmethod
def _label_block(label: str, value: Optional[str]) -> str:
safe_val = value if value not in (None, "") else ""
return f"{label}:\n{safe_val}"
2025-12-05 03:41:16 -08:00
class PlanetRenderer:
"""Type-specific table rendering for planets."""
def __init__(self, renderer: Renderer) -> None:
self.renderer = renderer
@property
def console(self) -> Console:
return self.renderer.console
def render(self, planet: Planet) -> None:
base_fields = {
"Symbol": planet.symbol,
"Element": planet.element,
"Ruling Zodiac": self._join_list(planet.ruling_zodiac),
"Associated Letters": self._join_list(planet.associated_letters),
"Keywords": self._join_list(planet.keywords),
"Description": planet.description,
}
self.renderer.print_kv_table(f"Planet: {planet.name}", self._compact(base_fields))
if planet.associated_numbers:
rows = []
for number in planet.associated_numbers:
color_label = self._color_label(number.color)
rows.append(
[
getattr(number, "value", ""),
getattr(number, "sephera", ""),
getattr(number, "element", ""),
getattr(number, "compliment", ""),
color_label,
]
)
self.renderer.print_rows_table(
"Associated Numbers",
["Value", "Sephera", "Element", "Compliment", "Color"],
rows,
)
if planet.color:
color = planet.color
color_fields = {
"Name": color.name,
"Hex": color.hex_value,
"Element": color.element,
"Scale": color.scale,
"Meaning": color.meaning,
"Tarot Associations": self._join_list(getattr(color, "tarot_associations", [])),
"Description": color.description,
}
self.renderer.print_kv_table("Planetary Color", self._compact(color_fields))
@staticmethod
def _join_list(items: Sequence[Any]) -> str:
return ", ".join(str(item) for item in items) if items else ""
@staticmethod
def _compact(data: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in data.items() if v not in (None, "", [], {})}
@staticmethod
def _color_label(color: Optional[Color]) -> str:
if isinstance(color, Color):
hex_part = f" ({color.hex_value})" if getattr(color, "hex_value", "") else ""
return f"{color.name}{hex_part}"
return ""