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 ""
|