Source code for numistalib.models.base.base_model

"""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] def format_field( label: str, value: Any, value_column: int | None = None, hanging_indent: int | None = None, short_line_field_separator: str = " " ) -> str: """Format a single field with label and value using Rich markup. Applies intelligent formatting based on value length and content: - Short values (≤25 chars): Same line, aligned at value_column - Long values (>25 chars): Next line at column 1 - Multiline values: Indented at 'indent' columns, extra linefeed after Parameters ---------- label : str Field label value : Any Field value value_column : int | None Column where values start (default: uses panel_value_column class property) indent : int | None Indentation for multiline values (default: uses hanging_indent class property) Returns ------- str Formatted field string with Rich markup and proper alignment """ # Use class properties as defaults value_column = value_column if value_column is not None else PANEL_VALUE_COLUMN hanging_indent = hanging_indent if hanging_indent is not None else PANEL_HANGING_INDENT # Scrub HTML and get cleaned value cleaned_value = scrub(value) # Format label with Rich markup formatted_label = f"[label]{label}:[/label]" # Detect multiline content is_multiline = "\n" in cleaned_value value_length = len(cleaned_value.split("\n")[0]) if is_multiline else len(cleaned_value) if is_multiline: # Multiline: indent each line, add extra linefeed at end lines = cleaned_value.split("\n") indent_str = " " * hanging_indent indented_lines = [f"{indent_str}{line}" for line in lines] formatted_value = "\n".join(indented_lines) return f"{formatted_label}\n{formatted_value}\n" elif value_length > 25: # Long single-line: start on next line at column 1, add extra linefeed return f"{formatted_label}\n{cleaned_value}\n" else: # Short value: same line, aligned at value_column # Calculate padding to align value at value_column label_width = len(label) + 1 # +1 for the colon padding_needed = max(1, value_column - label_width) padding = short_line_field_separator * padding_needed return f"{formatted_label}{padding}[value]{cleaned_value}[/value]"
[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] def format_field(self, label: str, width: int = 20, fill_char: str = " ") -> str: """Return a simple one-line formatted label/value string. Parameters ---------- label : str Field label to display before the value width : int Column at which value starts (minimum) fill_char : str Character used to pad between label and value """ label_text = f"{label}:" pad_len = max(0, width - len(label_text)) padding = fill_char * pad_len return f"{label_text}{padding}{self!s}"
[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"), )