"""Base models for all Numista entities.
Common configuration, behavior, and abstract base classes for Pydantic models.
"""
import re
from abc import ABC
from collections.abc import Iterable
from typing import Any, Self
import rich.repr
from bs4 import BeautifulSoup
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
from pydantic_core import core_schema
from rich.console import Console, ConsoleOptions, Group, RenderableType
from rich.text import Text
# Panel formatting constants
PANEL_VALUE_COLUMN: int = 20
PANEL_HANGING_INDENT: int = 5
[docs]
def safe(val: Any, default: str = "") -> str:
"""Return string representation or default if None or empty."""
return val if val is not None and str(val).strip() else default
[docs]
def scrub(value: Any) -> str:
"""Scrub HTML from field values, preserving formatting.
Uses BeautifulSoup4 to detect paragraphs, breaks, and links,
converting them to appropriate plain text with Rich markup.
Parameters
----------
value : Any
Field value (may contain HTML)
Returns
-------
str
Cleaned value with Rich markup for links
"""
text = safe(value)
if not type(text) == str:
return str(text)
# Check if value contains HTML
if ("<" in text and ">" in text):
soup = BeautifulSoup(text, "html.parser")
# Convert paragraphs to double newlines
for p in soup.find_all("p"):
p.replace_with(f"{p.get_text(strip=False)}\n")
# Convert breaks to single newlines
for br in soup.find_all("br"):
br.replace_with("\n")
# Fix links - convert to Rich markup
for link in soup.find_all("a"):
href = link.get("href", "")
link_text = link.get_text()
if href:
link.replace_with(f"[link={href}]{link_text}[/link]")
else:
link.replace_with(link_text)
# Get cleaned text
text = soup.get_text()
if text:
# clean up whitespace
text = re.sub(r"\n\n\n+", "\n\n", text.strip()) # collapse multiple newlines
text = re.sub(r"[ \t]+", " ", text)
# look for line feeds
cleaned_lines = [l.strip() for l in text.split("\n")]
text = "\n".join(cleaned_lines)
return text.strip()
[docs]
class NumistaBaseModel(ABC, BaseModel):
"""Base model for all Numista entities.
Provides a shared Pydantic base configuration with:
- Strict validation
- Immutability control
- Extra field prohibition
- Consistent configuration
"""
model_config = ConfigDict(
strict=True,
frozen=False,
extra="forbid",
validate_assignment=True,
arbitrary_types_allowed=False,
populate_by_name=True,
alias_generator=to_camel,
use_enum_values=True,
)
[docs]
def __rich_repr__(self) -> rich.repr.Result:
"""Make models look good when printed with rich.print()."""
for name, field in self.__class__.model_fields.items():
value = getattr(self, name)
if value is None: # Skip None fields for cleaner output
continue
if field.alias:
yield field.alias, value
else:
yield name, value
[docs]
def to_api_dict(self, **kwargs: Any) -> dict[Any, Any]:
"""Return dict suitable for sending back to API or clean export.
Uses aliases, excludes None by default.
"""
return self.model_dump(
by_alias=kwargs.get("by_alias", True),
exclude_none=kwargs.get("exclude_none", True),
exclude_unset=kwargs.get("exclude_unset", True),
**kwargs,
)
@property
def formatted_fields_dict(self) -> dict[str, str]:
"""Return dictionary of formatted strings for all model fields.
Automatically formats all fields (regular and computed) in the model
using format_field() with default column alignment and indentation.
Returns
-------
dict[str, str]
Dictionary mapping field names to formatted field strings
"""
formatted: dict[str, str] = {}
# Get all model fields including computed fields
for field_name in self.__class__.model_fields.keys():
value = getattr(self, field_name, None)
if value is not None: # Skip None values
# Convert field name to human-readable label
label = field_name.replace("_", " ").title()
formatted[field_name] = format_field(label, value)
# Also include computed fields
for field_name in self.__class__.model_computed_fields.keys():
if field_name in {"panel_template", "formatted_fields_dict"}:
continue # Skip these special fields
value = getattr(self, field_name, None)
if value is not None:
label = field_name.replace("_", " ").title()
formatted[field_name] = format_field(label, value)
return formatted
@property
def formatted_fields(self) -> list[str]:
"""Return list of formatted strings for all model fields.
Automatically formats all fields (regular and computed) in the model
using format_field() with default column alignment and indentation.
Returns
-------
list[str]
List of formatted field strings
"""
return list(self.formatted_fields_dict.values())
# def formatted_fields_deep(self) -> dict[str, str]:
# """Return formatted fields and recurse into related model lists without tables."""
# formatted: dict[str, str] = {}
# def process_field(field_name: str, value: Any) -> None:
# if value is None:
# return
[docs]
def render_compact(self) -> RenderableType:
"""Render compact representation for grid display.
Default implementation returns formatted title and key metadata.
Override in subclasses for custom compact display.
Returns
-------
RenderableType
Compact renderable for grid/column layout
"""
# Get a simple text representation with key fields
lines: list[str] = []
# Try to get a primary identifier
title = getattr(self, "title", None)
if title is not None:
lines.append(str(title))
else:
name = getattr(self, "name", None)
if name is not None:
lines.append(str(name))
else:
numista_id = getattr(self, "numista_id", None)
if numista_id is not None:
lines.append(f"ID: {numista_id}")
# Add secondary info if available
year = getattr(self, "year", None)
if year is not None:
lines.append(f"Year: {year}")
else:
min_year = getattr(self, "min_year", None)
max_year = getattr(self, "max_year", None)
if min_year is not None and max_year is not None:
year_info = str(min_year) if min_year == max_year else f"{min_year}-{max_year}"
lines.append(f"Years: {year_info}")
country = getattr(self, "country", None)
if country is not None:
country_name = getattr(country, "name", None)
if country_name is not None:
lines.append(str(country_name))
else:
lines.append(str(country))
else:
issuer = getattr(self, "issuer", None)
if issuer is not None:
issuer_name = getattr(issuer, "name", None)
if issuer_name is not None:
lines.append(str(issuer_name))
else:
lines.append(str(issuer))
return "\n".join(lines) if lines else str(self)
[docs]
@classmethod
def render_list(cls, items: list[Self]) -> Group | str:
"""Render list of items as Rich Group with horizontal rule separators.
Helper for CLI list rendering. Calls render_compact() on each item
and adds horizontal rules between items when there are multiple.
Parameters
----------
items : list[Self]
List of model instances to render
Returns
-------
Group | str
Rich Group containing all items with separators, or message if empty
"""
if not items:
return "No items available"
content: list[RenderableType] = []
for i, item in enumerate(items):
content.append(item.render_compact())
# Add horizontal rule separator between items (not after last)
if i < len(items) - 1:
content.append("") # Blank line
content.append("─" * 80) # Horizontal rule
content.append("") # Blank line
return Group(*content)
[docs]
def as_panel(self, style_overrides: dict[str, Any] | None = None) -> Any:
"""Render model as Rich Panel.
Default implementation creates a panel with formatted fields.
Override in subclasses for custom panel layout.
Parameters
----------
style_overrides : dict[str, Any] | None
Optional style overrides for the panel
Returns
-------
Panel
Rich Panel with model data
"""
from numistalib.cli.theme import CLISettings
# Get formatted fields as content
content = "\n".join(self.formatted_fields)
# Get title (try common field names)
title = None
for field_name in ["title", "name", "code"]:
if hasattr(self, field_name):
title = getattr(self, field_name)
break
if not title:
title = self.__class__.__name__
return CLISettings.panel(content=content, title=str(title))
[docs]
class RichField:
"""Wrapper for Rich renderables to use as Pydantic fields.
Stores any Rich renderable (Panel, Table, Text, or plain string/None)
and implements the Rich console protocol for direct rendering.
Also provides a simple `format_field` helper used by tests to produce
aligned label/value output.
"""
def __init__(self, value: Any | None = None) -> None:
self.value = value
def __str__(self) -> str:
if self.value is None:
return ""
return str(self.value)
# Rich console protocol
def __rich_console__(self, console: Console, options: ConsoleOptions) -> Iterable[RenderableType]:
if self.value is None:
yield Text("")
elif isinstance(self.value, (str, Text)):
yield self.value if isinstance(self.value, Text) else Text(self.value)
else:
# Assume it's a Rich renderable; let Rich handle it
yield self.value # type: ignore[misc]
[docs]
@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema: # type: ignore[name-defined]
"""Pydantic v2 integration: accept any input and wrap as RichField.
Allows using `RichField` as a typed field without enabling
`arbitrary_types_allowed` globally.
"""
def _validate(value: Any) -> "RichField":
return value if isinstance(value, RichField) else RichField(value)
return core_schema.no_info_after_validator_function(
_validate,
core_schema.any_schema(),
serialization=core_schema.plain_serializer_function_ser_schema(str, when_used="json"),
)