"""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, Tuple from rich import box from rich.console import Console, Group from rich.panel import Panel from rich.table import Table from tarot import Card from utils.attributes import Color, Planet from utils.dates import format_month_day from utils.object_formatting import format_value, get_object_attributes, is_nested_object 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: for idx, card in enumerate(cards): self._render_card(card) if idx < len(cards) - 1: self.console.print() # Internal renderers ------------------------------------------------- def _render_card(self, card: Card) -> None: """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 "" number = getattr(card, "number", "") keywords = getattr(card, "keywords", None) or [] meaning = getattr(card, "meaning", None) 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 = self._card_field_lines(card) 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), ) card_panel = Panel( Group(top, body, bottom), box=box.SQUARE, padding=0, title=name, expand=False, ) self.console.print(card_panel) 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("_", " ") @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}" def _card_field_lines(self, card: Card) -> List[str]: exclude = {"name", "number", "meaning", "keywords"} lines: List[str] = [] for attr_name, attr_value in get_object_attributes(card): if attr_name in exclude: continue if attr_value in (None, "", [], {}, ()): # Skip empty values continue label = self._humanize_key(attr_name).title() if is_nested_object(attr_value) and not hasattr(attr_value, "name"): lines.append(f"{label}:") nested = format_value(attr_value, indent=2) lines.extend(nested.splitlines()) continue lines.append(f"{label}: {self._format_card_value(attr_value)}") if not lines: lines.append("(no fields)") return lines def _format_card_value(self, value: Any) -> str: if value is None: return "—" if isinstance(value, (str, int, float, bool)): return str(value) if isinstance(value, tuple): date_range = self._format_date_range(value) if date_range is not None: return date_range return ", ".join(self._format_card_value(v) for v in value) if isinstance(value, list): return ", ".join(self._format_card_value(v) for v in value) if isinstance(value, dict): return ", ".join( f"{self._humanize_key(str(k)).title()}: {self._format_card_value(v)}" for k, v in value.items() ) if hasattr(value, "name"): return str(getattr(value, "name", value)) if is_nested_object(value): return format_value(value, indent=2) return str(value) @staticmethod def _format_date_range(value: Tuple[Any, ...]) -> Optional[str]: if len(value) != 2: return None start, end = value if not ( isinstance(start, tuple) and isinstance(end, tuple) and len(start) == 2 and len(end) == 2 ): return None try: return f"{format_month_day(start)} - {format_month_day(end)}" except Exception: return None 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 ""