Source code for numistalib.models.types

"""Numista catalogue type models.

Pydantic models for Numista coin/banknote/exonumia types.
Used for validation and data transfer between integration and service layers.
"""

from __future__ import annotations

import re
from abc import ABC
from datetime import date
from functools import cached_property
from io import BytesIO
from typing import Annotated, Any, Literal

import httpx
from bs4 import BeautifulSoup
from PIL import Image as PILImage
from pydantic import (
    AnyUrl,
    Field,
    HttpUrl,
    StringConstraints,
    computed_field,
    field_validator,
    model_validator,
)
from pydantic_core import Url
from rich.table import Table
from rich.text import Text

try:
    # textual_image may query terminal capabilities at import time and fail in non-TTY environments
    # Guard the import to avoid raising during test collection; fall back to a no-op stub when unavailable.
    from textual_image.renderable import Image as TImage
except (ImportError, OSError, RuntimeError):
    class TImage:  # type: ignore[override]
        def __init__(self, *args: object, **kwargs: object) -> None:
            self._placeholder = "[image unavailable]"

        def __rich_console__(self, console: object, options: object):
            from rich.text import Text
            yield Text(self._placeholder)

from numistalib.models import Currency, Issuer, Mint, NumistaBaseModel, Reference
from numistalib.models.issues import IssueTerms


[docs] class Country(NumistaBaseModel): """Country information with code and name.""" code: str = Field(max_length=50) name: str = Field(max_length=255)
[docs] def __str__(self) -> str: """Return formatted country with code and name.""" return f"{self.name} ({self.code})"
[docs] class CurrencyValue(NumistaBaseModel): """Currency value information.""" text: str = Field(description="Textual representation of the value") numeric_value: float | None = Field(None, description="Numeric value") numerator: int | None = Field(None, description="Numerator for fractional values") denominator: int | None = Field(None, description="Denominator for fractional values") currency: Currency | None = Field(None, description="Currency information")
[docs] class Demonetization(NumistaBaseModel): """Demonetization status and date information.""" is_demonetized: bool = Field(description="Indicates if the type is demonetized") demonetization_date: date | None = Field(None, description="Date of demonetization")
[docs] @field_validator("demonetization_date", mode="before") @classmethod def parse_partial_date(cls, v: Any) -> date | None: """Handle partial dates with 00 day or month and various formats.""" if v is None: return None if isinstance(v, date): return v if isinstance(v, str): # Skip partial dates with 00 if "00" in v or v.endswith("-00"): return None # Try multiple date formats from datetime import datetime formats = [ "%Y-%m-%d", # 1988-02-15 "%Y/%m/%d", # 1988/02/15 "%d-%m-%Y", # 15-02-1988 "%d/%m/%Y", # 15/02/1988 "%m-%d-%Y", # 02-15-1988 "%m/%d/%Y", # 02/15/1988 "%Y", # 1988 (year only) ] for fmt in formats: try: return datetime.strptime(v, fmt).date() except ValueError: continue return None return None
[docs] class Composition(NumistaBaseModel): # noqa: PLW1641 """Metal composition information for coin types.""" text: str = Field(description="Metal composition description") def __eq__(self, other: object) -> bool: # Allow test equality to string value if isinstance(other, str): return self.text == other return super().__eq__(other)
[docs] class Technique(NumistaBaseModel): """Minting technique information.""" text: str = Field(description="Minting technique description")
[docs] class LetteringScript(NumistaBaseModel): """Lettering script information for coin designs.""" name: str = Field(description="Name of the lettering script")
[docs] class References(NumistaBaseModel): """Collection of references for a type.""" references: list[Reference] = Field(description="List of references")
[docs] class RulerGroup(NumistaBaseModel): """Ruler group information for hierarchical ruler organization.""" id: int = Field(..., description="Numista ID", alias="numista_id") name: str = Field(..., description="Group Name")
[docs] class Ruler(NumistaBaseModel): """Maps to Numista ruler schema for detailed ruler information.""" id: int = Field(..., description="Numista ID", alias="numista_id") name: str = Field(..., description="Ruler Name") wikidata_id: str | None = Field(None, description="Wikidata ID") nomisma_id: str | None = Field(None, description="Nomisma ID") group: RulerGroup | None = Field(None, description="Group information") @computed_field @property def wikidata_url(self) -> Url | None: """Computed Wikidata URL from `wikidata_id`. Returns ------- Url | None Full Wikidata URL if `wikidata_id` present, else None. """ if self.wikidata_id: return Url(f"https://www.wikidata.org/wiki/{self.wikidata_id}") return None @computed_field @property def group_id(self) -> int | None: """Extract group ID from group. Returns ------- int | None Group ID if group present, else None. """ return self.group.id if self.group else None @computed_field @property def group_name(self) -> str | None: """Extract group name from group. Returns ------- str | None Group name if group present, else None. """ return self.group.name if self.group else None
[docs] def render_compact(self) -> str: """Render compact ruler display.""" lines = [f"[bold]{self.name}[/bold]"] if self.wikidata_id: lines.append(f"[link={self.wikidata_url}]Wikidata: {self.wikidata_id}[/link]") if self.group: lines.append(f"[dim]Group: {self.group.name}[/dim]") return "\n".join(lines)
[docs] @classmethod def render_table(cls, items: list[Ruler], title: str = "") -> Table: """Generate table for ruler list with computed group fields. Parameters ---------- items : list[Ruler] List of Ruler instances title : str Table title Returns ------- Table Rich table with ruler information """ from rich.table import Table table = Table(show_header=True, box=None, pad_edge=False, title=title, expand=True) table.add_column("Id", no_wrap=True) table.add_column("Name", no_wrap=True) table.add_column("Wikidata Id", no_wrap=True) table.add_column("Nomisma Id", no_wrap=True) table.add_column("Group Id", no_wrap=True) table.add_column("Group Name", no_wrap=True) for ruler in items: table.add_row( str(ruler.id), ruler.name, ruler.wikidata_id or "", ruler.nomisma_id or "", str(ruler.group_id) if ruler.group_id else "", ruler.group_name or "" ) return table
[docs] class SideBase(NumistaBaseModel, ABC): """Base class for obverse and reverse sides of a type.""" engravers: list[str] | None = Field(None, description="List of engravers") designers: list[str] | None = Field(None, description="List of designers") description: str | None = Field(None, description="Description of the side") lettering: str | None = Field(None, description="Lettering/inscription on the side") lettering_scripts: list[LetteringScript] | None = Field(None, description="Lettering scripts used") picture: AnyUrl = Field(description="Picture URL of the side") thumbnail: AnyUrl = Field(description="Thumbnail URL of the side") picture_copyright: str | None = Field(None, description="Picture copyright information") picture_copyright_url: AnyUrl | None = Field(None, description="URL for picture copyright information") picture_license_name: str | None = Field(None, description="Picture license name") picture_license_url: AnyUrl | None = Field(None, description="Picture license URL") lettering_translation: str | None = Field(None, description="Translation of the lettering/inscription") unabridged_legend: str | None = Field(None, description="Full unabridged lettering") @computed_field(description="Cleaned lettering as lines") def lettering_lines(self) -> list[str]: """Splits the lettering into individual lines using the embedded line break.""" if not self.lettering: return [] return [line.strip() for line in self.lettering.splitlines() if line.strip()]
[docs] @cached_property def pillow_image(self) -> PILImage.Image | None: """Download and cache the full picture as a Pillow Image.""" response = httpx.get(self.picture.encoded_string(), follow_redirects=True, timeout=30.0) response.raise_for_status() return PILImage.open(BytesIO(response.content))
[docs] @cached_property def pillow_thumbnail(self) -> PILImage.Image | None: """Download and cache the thumbnail as a Pillow Image.""" response = httpx.get(self.thumbnail.encoded_string(), follow_redirects=True, timeout=30.0) response.raise_for_status() return PILImage.open(BytesIO(response.content))
[docs] @cached_property def renderable_image(self) -> Any | None: """Ready-to-print textual_image renderable (full picture).""" if self.pillow_image: return TImage(self.pillow_image)
[docs] @cached_property def renderable_thumbnail(self) -> Any | None: """Ready-to-print thumbnail.""" if self.pillow_thumbnail: return TImage(self.pillow_thumbnail)
@computed_field(description="Formatted copyright link for textual display") def copyright_link(self) -> str: """Formatted copyright link for textual display.""" return f"[link={self.picture_copyright_url}]{self.picture_copyright}[/link]" @computed_field(description="Formatted thumbnail link for textual display") def thumbnail_link(self) -> str: """Formatted thumbnail link for textual display.""" return f"[link={self.thumbnail}]{self.thumbnail}[/link]" @computed_field(description="Formatted picture link for textual display") def picture_link(self) -> str: """Formatted picture link for textual display.""" return f"[link={self.picture}]{self.picture}[/link]" @property def formatted_fields(self) -> list[str]: """Return formatted fields excluding redundant computed fields and formatting lists properly.""" from numistalib.models.base.base_model import format_field formatted = [] # Define fields to exclude (redundant computed fields) exclude_fields = { "lettering_lines", # Redundant with lettering "copyright_link", # Redundant with picture_copyright "thumbnail_link", # Redundant with thumbnail "picture_link", # Redundant with picture } # Process regular fields for field_name in self.__class__.model_fields.keys(): if field_name in exclude_fields: continue value = getattr(self, field_name, None) if value is None: continue label = field_name.replace("_", " ").title() # Format lists specially if isinstance(value, list): if not value: continue # Check if it's a list of models if all(hasattr(item, "__class__") and hasattr(item.__class__, "__name__") for item in value): # List of objects - extract names if hasattr(value[0], "name"): formatted_value = ", ".join(str(item.name) for item in value) else: formatted_value = ", ".join(str(item) for item in value) else: # Simple list formatted_value = ", ".join(str(item) for item in value) formatted.append(format_field(label, formatted_value)) else: formatted.append(format_field(label, value)) # Process computed fields (excluding those in exclude set) for field_name in self.__class__.model_computed_fields.keys(): if field_name in exclude_fields or field_name == "formatted_fields": continue value = getattr(self, field_name, None) if value is not None: label = field_name.replace("_", " ").title() formatted.append(format_field(label, value)) return formatted
[docs] class Obverse(SideBase): """Obverse (front/heads) side of a coin or banknote.""" pass
[docs] class Reverse(SideBase): """Reverse (back/tails) side of a coin or banknote.""" pass
[docs] class Edge(NumistaBaseModel): """Edge specifications for a coin type. Parameters ---------- description : str | None Description of the edge (e.g., 'Reeded', 'Plain') picture : str | None Picture URL of the edge thumbnail : str | None Thumbnail URL of the edge picture_copyright : str | None Copyright information for the picture lettering : str | None Lettering on the edge lettering_scripts : list[dict[str, Any]] | None Scripts used in edge lettering lettering_translation : str | None Translation of edge lettering """ description: str | None = Field(None, description="Edge description (e.g., 'Reeded')") picture: str | None = Field(None, description="Edge picture URL") thumbnail: str | None = Field(None, description="Edge thumbnail URL") picture_copyright: str | None = Field(None, description="Edge picture copyright") picture_copyright_url: str | None = Field(None, description="Edge picture copyright URL") lettering: str | None = Field(None, description="Edge lettering text") lettering_scripts: list[dict[str, Any]] | None = Field(None, description="Scripts used in edge lettering") lettering_translation: str | None = Field(None, description="Translation of edge lettering")
[docs] @cached_property def pillow_image(self) -> PILImage.Image | None: """Download and cache the full picture as a Pillow Image.""" if self.picture: response = httpx.get(self.picture, follow_redirects=True, timeout=30.0) response.raise_for_status() return PILImage.open(BytesIO(response.content))
[docs] @cached_property def pillow_thumbnail(self) -> PILImage.Image | None: """Download and cache the thumbnail as a Pillow Image.""" if self.thumbnail: response = httpx.get(self.thumbnail, follow_redirects=True, timeout=30.0) response.raise_for_status() return PILImage.open(BytesIO(response.content))
[docs] @cached_property def renderable_image(self) -> Any | None: """Ready-to-print textual_image renderable (full picture).""" if self.pillow_image: return TImage(self.pillow_image)
[docs] @cached_property def renderable_thumbnail(self) -> Any | None: """Ready-to-print thumbnail.""" if self.pillow_thumbnail: return TImage(self.pillow_thumbnail)
[docs] class Watermark(SideBase): """Watermark specifications for banknotes. Extends SideBase with all standard side fields (engravers, designers, description, lettering, picture, etc.) for watermark representation. """ pass
[docs] class Printer(NumistaBaseModel): """Printer information for banknotes. Similar to Mint but for banknote printers. Parameters ---------- id : int Unique printer ID name : str Printer name """ id: int = Field(..., description="Unique printer ID") name: str = Field(..., description="Printer name")
[docs] class TypeBase(NumistaBaseModel, ABC): """Common fields shared between basic and full type representations.""" numista_id: int = Field(alias="id", gt=0, description="Numista ID") title: Annotated[str, StringConstraints(strip_whitespace=True, max_length=500)] = Field( ..., description="Type title" ) category: Literal["coin", "banknote", "exonumia"] = Field( ..., description="Category: coin, banknote, exonumia" ) min_year: int | None = Field(None, ge=-9999, le=9999, description="First year of production") # Allow BC dates max_year: int | None = Field(None, ge=-9999, le=9999, description="Last year of production") @computed_field(description="Human-readable year range") def year_range(self) -> str: """Get human-readable year range.""" if self.min_year and self.max_year: if self.min_year == self.max_year: return str(self.min_year) return f"{self.min_year}-{self.max_year}" elif self.min_year: return f"{self.min_year}-" elif self.max_year: return f"-{self.max_year}" return "Undated" @computed_field(description="Canonical Numista page URL") def numista_url(self) -> HttpUrl: """Get canonical Numista URL for this type.""" return HttpUrl(f"https://en.numista.com/{self.numista_id}")
[docs] @model_validator(mode="after") def validate_years(self) -> TypeBase: """Validate that min_year does not exceed max_year.""" if self.min_year is not None and self.max_year is not None: if self.min_year > self.max_year: raise ValueError("min_year cannot be greater than max_year") return self
[docs] class TypeBasic(TypeBase): """Basic type information from search results.""" issuer: Issuer | None = Field(None, description="Issuer information") country: Country | None = Field(None, description="Country information (used in related_types)") obverse_thumbnail: HttpUrl | None = Field(None, description="Obverse thumbnail URL") reverse_thumbnail: HttpUrl | None = Field(None, description="Reverse thumbnail URL") @model_validator(mode="before") @classmethod def _map_legacy_fields(cls, data: Any) -> Any: """Map legacy flat fields to structured ones and drop extras. Supports tests providing `issuer_code`/`issuer_name` (legacy) by composing an `issuer` dict, and removes the legacy keys to avoid extra field errors. """ if not isinstance(data, dict): return data code = data.pop("issuer_code", None) name = data.pop("issuer_name", None) if (code or name) and "issuer" not in data: data["issuer"] = {"code": code or "", "name": name or ""} return data
[docs] def render_compact(self) -> Any: """Render compact representation with thumbnail and formatted fields. Returns ------- Any Rich renderable (Group with thumbnail + text or just text) """ from rich.console import Group # Use formatted_fields for consistent DRY formatting text_block = "\n".join(self.formatted_fields) # Try to render thumbnail try: if self.obverse_thumbnail is not None: response = httpx.get(str(self.obverse_thumbnail), follow_redirects=True, timeout=10.0) response.raise_for_status() image = PILImage.open(BytesIO(response.content)) thumb = TImage(image) return Group(thumb, text_block) except Exception: # Fallback to text only pass return text_block
[docs] @classmethod def render_table(cls, items: list[TypeBasic], title: str = "") -> Table: """Render a concise table for type search results. Columns include key catalogue search fields expected by the CLI: Numista ID, Type title, Category, First year, Last year, Issuer, Country, Obverse thumb URL, Reverse thumb URL. Issuer is rendered as a short name (link when possible). """ table = Table(show_header=True, box=None, pad_edge=False, title=title) table.add_column("Numista ID", no_wrap=True) table.add_column("Type title", no_wrap=False) table.add_column("Category", no_wrap=True) table.add_column("First year", no_wrap=True) table.add_column("Last year", no_wrap=True) table.add_column("Issuer", no_wrap=False) table.add_column("Country", no_wrap=False) table.add_column("Obverse thumb URL", no_wrap=False, overflow="fold") table.add_column("Reverse thumb URL", no_wrap=False, overflow="fold") for t in items: # Issuer display: prefer name; add link when available issuer_text = "" if t.issuer: name = getattr(t.issuer, "name", None) or "" # Use Wikidata link if present as a safe external reference wikidata_id = getattr(t.issuer, "wikidata_id", None) if wikidata_id: issuer_text = f"[link=https://www.wikidata.org/wiki/{wikidata_id}]{name}[/link]" else: issuer_text = name def _link_cell(url: HttpUrl | None) -> Text: if not url: return Text("") display = str(url) cell = Text(display) cell.stylize(f"link {display}", 0, len(display)) return cell table.add_row( str(t.numista_id), t.title, t.category, str(t.min_year) if t.min_year is not None else "", str(t.max_year) if t.max_year is not None else "", issuer_text, (t.country.name if getattr(t, "country", None) and getattr(t.country, "name", None) else ""), _link_cell(t.obverse_thumbnail), _link_cell(t.reverse_thumbnail), ) return table
[docs] def to_dict(self) -> dict[str, object]: """Return a compact dict representation used by tests.""" return { "numista_id": self.numista_id, "title": self.title, "category": self.category, }
[docs] class TypeFull(TypeBase): """Full type details including physical specifications."""
[docs] @model_validator(mode="before") @classmethod def preprocess_data(cls, data: Any) -> Any: """Preprocess API data to normalize nested structures.""" if not isinstance(data, dict): return data # Back-compat: accept legacy issuer fields code = data.pop("issuer_code", None) name = data.pop("issuer_name", None) if (code or name) and "issuer" not in data: data["issuer"] = {"code": code or "", "name": name or ""} # Drop thumbnails not present in full schema data.pop("obverse_thumbnail", None) data.pop("reverse_thumbnail", None) # Map diameter -> size if "diameter" in data and "size" not in data: try: data["size"] = float(data.pop("diameter")) except Exception: data.pop("diameter", None) # Map value_* -> value struct vt = data.pop("value_text", None) vn = data.pop("value_numeric", None) cn = data.pop("currency_name", None) if (vt is not None) or (vn is not None) or (cn is not None): value_obj: dict[str, Any] = {} if vt is not None: value_obj["text"] = vt if vn is not None: try: value_obj["numeric_value"] = float(vn) except Exception: pass # Currency requires many fields; skip constructing if only name present data["value"] = value_obj # Map obverse_* and reverse_* flat fields ob_desc = data.pop("obverse_description", None) ob_let = data.pop("obverse_lettering", None) if (ob_desc is not None) or (ob_let is not None): ob: dict[str, Any] = {} if ob_desc is not None: ob["description"] = ob_desc if ob_let is not None: ob["lettering"] = ob_let # Provide dummy required URLs when absent for validation convenience in tests ob.setdefault("picture", "https://example.com/obverse.jpg") ob.setdefault("thumbnail", "https://example.com/obverse_t.jpg") data["obverse"] = ob rv_desc = data.pop("reverse_description", None) rv_let = data.pop("reverse_lettering", None) if (rv_desc is not None) or (rv_let is not None): rv: dict[str, Any] = {} if rv_desc is not None: rv["description"] = rv_desc if rv_let is not None: rv["lettering"] = rv_let rv.setdefault("picture", "https://example.com/reverse.jpg") rv.setdefault("thumbnail", "https://example.com/reverse_t.jpg") data["reverse"] = rv # Accept simple string for composition comp = data.get("composition") if isinstance(comp, str): data["composition"] = {"text": comp} # Ensure url present (required); synthesize if missing if "url" not in data and (nid := data.get("numista_id") or data.get("id")): try: nid_int = int(nid) data["url"] = f"https://example.com/types/{nid_int}" except Exception: data["url"] = "https://example.com/types/unknown" # Normalize references catalogue field from dict to string if "references" in data and isinstance(data["references"], list): for ref in data["references"]: if isinstance(ref, dict) and "catalogue" in ref: cat = ref["catalogue"] if isinstance(cat, dict) and "code" in cat: ref["catalogue"] = cat["code"] return data
# Required fields that only exist in full version url: HttpUrl = Field(..., description="Numista URL") issuer: Issuer # Optional full-only fields issue_terms: IssueTerms | None = Field(None, alias="issueTerms", description="Issue Terms") issuing_entity: dict[str, Any] | None = Field(None, description="① Issuing entity") secondary_issuing_entity: dict[str, Any] | None = Field(None, description="② Issuing entity") type: str | None = Field(None, max_length=100) rulers: list[Ruler] | None = Field(None, alias="ruler") value: CurrencyValue | None = Field(None) demonetization: Demonetization | None = Field(None) size: float | None = Field(None, ge=0, description="① size mm") size2: float | None = Field(None, ge=0, description="② size mm") thickness: float | None = Field(None, ge=0) weight: float | None = Field(None, ge=0) shape: str | None = Field(None, max_length=100) composition: Composition | None = Field(None) technique: Technique | None = Field(None) obverse: Obverse | None = Field(None) reverse: Reverse | None = Field(None) watermark: Watermark | None = Field(None, description="Watermark (notes)") references: list[Reference] | None = Field(None) mints: list[Mint] | None = Field(None) printers: list[Printer] | None = Field(None, description="Printers (notes)") comments: str | None = Field(None) tags: list[str] | None = Field(None) edge: Edge | None = Field(None) related_types: list[TypeBasic] | None = Field(None) # Note: recursive, but TypeBasic is defined orientation: str | None = Field(None) series: str | None = Field(None, description="Series/set") commemorated_topic: str | None = Field(None, description="Topic commemorated") @computed_field @property def orientation_symbol(self) -> str | None: """Return symbol representation of orientation. Returns ⇈ (upup arrows) for medal, ⇅ (updown arrows) for coin, or None. """ if not self.orientation: return None orientation_lower = self.orientation.lower() if "medal" in orientation_lower: return "⇈" elif "coin" in orientation_lower: return "⇅" return None @computed_field @property def comments_rendered(self) -> str | None: """Return terminal-safe scrubbed comments. Comments field may contain HTML with line feeds, links, images, and formatting. This computed field scrubs HTML artifacts for clean terminal display with Rich markup. Returns ------- str | None Scrubbed comments ready for terminal rendering, or None if no comments """ if not self.comments: return None text = self.comments # Check if value contains HTML if "<" in text and ">" in text: soup = BeautifulSoup(text, "html.parser") # First pass: Replace image links with inline marker before text extraction # Pattern: description text <br/> <a><img></a> <br/> next description # Strategy: Find all <a> tags with images and mark them before processing breaks for link in soup.find_all("a"): href = link.get("href", "") img = link.find("img") if img and href: # Replace with inline link marker link.replace_with(f" [link={href}](image)[/link]") else: # Regular text link link_text = link.get_text(strip=True) if href and link_text: link.replace_with(f" [link={href}]{link_text}[/link]") elif link_text: link.replace_with(f" {link_text}") else: link.decompose() # Remove standalone images for img in soup.find_all("img"): img.decompose() # Convert paragraphs for p in soup.find_all("p"): p.insert_before("\n") p.insert_after("\n") p.unwrap() # Convert <br/> to newlines for br in soup.find_all("br"): br.replace_with("\n") # Get text text = soup.get_text() if text: # Clean up whitespace text = re.sub(r"\r\n", "\n", text) # Normalize line endings text = re.sub(r"[ \t]+", " ", text) # Normalize spaces # Merge lines: if a line starts with whitespace + [link=, append it to previous line text = re.sub(r"\n+\s*(\[link=)", r" \1", text) # Collapse excessive newlines text = re.sub(r"\n\n\n+", "\n\n", text) # Strip each line but keep line structure lines = [line.strip() for line in text.split("\n")] # Remove empty lines lines = [line for line in lines if line] text = "\n".join(lines) return text.strip() if text else None
[docs] def render_detail(self, cache_indicator: str = "") -> Any: """Render detailed type information using theme-aware, vertical scrolling layout. Parameters ---------- cache_indicator : str Cache indicator (e.g., "💾" for cached, "🌐" for fresh) Returns ------- Any Group of Rich panels for display """ import textwrap from rich.console import Group from numistalib.cli.theme import CLISettings from numistalib.models.mints import Mint from numistalib.models.references import Reference # General panel - filter out None values general_lines = [] for key in ["numista_id", "numista_url", "title", "series", "category", "year_range"]: field = self.formatted_fields_dict.get(key) if field: general_lines.append(field) if self.demonetization: demonetized = self.demonetization.formatted_fields_dict.get("is_demonetized") if demonetized: general_lines.append(demonetized) commemorated = self.formatted_fields_dict.get("commemorated_topic") if commemorated: general_lines.append(commemorated) if self.tags: general_lines.append("[header]Tags:[/header]") general_lines.append(" ".join([f"[inverse]{g}[/inverse]" for g in self.tags])) general_panel = CLISettings.panel( title=f"{cache_indicator} Type Details", content="\n".join(general_lines) ) # Value panel - filter out None values value_lines = [] if self.value: for key in ["text", "numeric_value", "numerator", "denominator"]: field = self.value.formatted_fields_dict.get(key) if field: value_lines.append(field) if self.value.currency: value_lines.append("[header]Currency:[/header]") for key in ["name", "full_name", "symbol", "numista_id"]: field = self.value.currency.formatted_fields_dict.get(key) if field: value_lines.append(field) value_panel = CLISettings.panel( title="Value", content="\n".join(value_lines) if value_lines else "" ) # Issuer panel - filter out None values issuer_lines = [] for key in ["issuing_entity", "issue_terms"]: field = self.formatted_fields_dict.get(key) if field: issuer_lines.append(field) if self.issuer: for key in ["code", "name"]: field = self.issuer.formatted_fields_dict.get(key) if field: issuer_lines.append(field) issuer_panel = CLISettings.panel( title="Issuer", content="\n".join(issuer_lines) if issuer_lines else "" ) mints_panel = CLISettings.panel( title="Mints", content=Mint.render_table(self.mints, title="") if self.mints else "" ) # Physical specifications panel - filter out None values specs_lines = [] for key in ["orientation", "shape", "size", "thickness"]: field = self.formatted_fields_dict.get(key) if field: specs_lines.append(field) if self.composition: specs_lines.append("[header]Composition:[/header]") comp_text = self.composition.formatted_fields_dict.get("text") if comp_text: specs_lines.append(comp_text) specs_panel = CLISettings.panel( title="Physical Specifications", content="\n".join(specs_lines) if specs_lines else "" ) edge_panel = CLISettings.panel( title="Edge Specifications", content=Group( "\n".join(list(self.edge.formatted_fields)) if self.edge else "", self.edge.renderable_thumbnail if self.edge and self.edge.renderable_thumbnail else "" ) if self.edge else "" ) obverse_panel = CLISettings.panel( title="Obverse Specifications", content=Group( "\n".join(list(self.obverse.formatted_fields)), self.obverse.renderable_thumbnail if self.obverse.renderable_thumbnail else "" ) ) reverse_panel = CLISettings.panel( title="Reverse Specifications", content=Group( "\n".join(list(self.reverse.formatted_fields)), self.reverse.renderable_thumbnail if self.reverse.renderable_thumbnail else "" ) ) rulers_panel = CLISettings.panel( title="Rulers", content=Ruler.render_table(self.rulers, title="") if self.rulers else "" ) references_panel = CLISettings.panel( title="References", content=Reference.render_table(self.references, title="") if self.references else "" ) # Related types panel - use list rendering with thumbnails related_types_panel = CLISettings.panel( title="Related Types", content=TypeBasic.render_list(self.related_types) if self.related_types else "" ) # Comments panel - promote label to title (single field) # Pre-wrap text to panel width to avoid mid-word breaks comments_text = "" if self.comments_rendered: # Wrap each line to panel width - 4 (for panel borders/padding) wrapped_lines = [] for line in self.comments_rendered.split("\n"): # Use textwrap to break long lines at word boundaries if len(line) > (CLISettings.PANEL_WIDTH - 4): wrapped = textwrap.fill(line, width=CLISettings.PANEL_WIDTH - 4, break_long_words=False, break_on_hyphens=False) wrapped_lines.append(wrapped) else: wrapped_lines.append(line) comments_text = "\n".join(wrapped_lines) comments_panel = CLISettings.panel( title="Comments", content=comments_text ) return ( general_panel, value_panel, issuer_panel, mints_panel, specs_panel, edge_panel, obverse_panel, reverse_panel, rulers_panel, references_panel, related_types_panel, comments_panel, )