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