Files
tarot/cli_renderers.py

326 lines
12 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
from rich.console import Console
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:
suit = getattr(card, "suit", None)
lines: List[str] = [f"Arcana: {card.arcana}"]
if suit is not None and getattr(suit, "name", None):
lines.append(f"Suit: {suit.name}")
if getattr(card, "pip", 0):
lines.append(f"Pip: {card.pip}")
if getattr(card, "court_rank", ""):
lines.append(f"Court Rank: {card.court_rank}")
meaning = getattr(card, "meaning", None)
if meaning is not None:
if getattr(meaning, "upright", None):
lines.append(f"Upright: {meaning.upright}")
if getattr(meaning, "reversed", None):
lines.append(f"Reversed: {meaning.reversed}")
keywords = getattr(card, "keywords", None)
if keywords:
lines.append(f"Keywords: {', '.join(keywords)}")
reversed_keywords = getattr(card, "reversed_keywords", None)
if reversed_keywords:
lines.append(f"Reversed Keywords: {', '.join(reversed_keywords)}")
guidance = getattr(card, "guidance", None)
if guidance:
lines.append(f"Guidance: {guidance}")
self.console.print(
Panel("\n".join(lines), title=f"{card.number}: {card.name}", expand=False)
)
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("_", " ")
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 ""