Source code for numistalib.models.issues

"""Numista issues models.

Pydantic models for Numista coin issues (specific years/mints of a type).
"""

from datetime import date, datetime
from typing import Any, Self

from pydantic import Field, HttpUrl, computed_field, field_validator
from rich.table import Table

from numistalib.models.base import NumistaBaseModel
from numistalib.models.references import Reference


[docs] class Mark(NumistaBaseModel): """Mint mark, privy mark, or die mark on a coin. Parameters ---------- mark_id : int Unique ID of the mark title : str | None Title/name of the mark (optional) picture : HttpUrl | None URL to picture of the mark (optional) letters : str | None Letters of the mark (optional) Notes ----- Marks either have a picture or letters, not both. """ mark_id: int = Field(description="Mark ID") title: str | None = Field(None, description="Mark title") picture: HttpUrl | None = Field(None, description="URL to mark picture") letters: str | None = Field(None, description="Mark letters")
[docs] class Signature(NumistaBaseModel): """Signature on a banknote. Parameters ---------- signer_name : str Name of the person who signed signer_title : str | None Job title of the person who signed (optional) """ signer_name: str = Field(description="Signer name") signer_title: str | None = Field(None, description="Signer job title")
[docs] class Issue(NumistaBaseModel): """Coin issue (specific year/mint of a type). Maps to Numista issue entity. Issues represent specific mintings of a type with particular year, mint mark, or variety. Parameters ---------- numista_id : int Numista issue ID (unique identifier) type_id : int Parent type ID is_dated : bool Whether issue has a visible date on the coin year : int | None Year visible on coin (optional) gregorian_year : int | None Gregorian calendar year (optional) mint_letter : str | None Mint mark or letter (max 10 chars, optional) mintage : int | None Number of coins minted (optional) comment : str | None Additional notes or variety information (optional) raw : dict Original API payload Raises ------ ValidationError If required fields missing or invalid types Examples -------- >>> issue = Issue( ... numista_id=123456, ... type_id=95420, ... is_dated=True, ... year=1622, ... gregorian_year=1622, ... mint_letter="D", ... mintage=50000, ... raw={} ... ) >>> print(f"{issue.year} {issue.mint_letter}") 1622 D """ numista_id: int = Field(description="Numista issue ID") type_id: int = Field(description="Parent type ID") is_dated: bool = Field(description="Whether issue has a visible date") year: int | None = Field(None, description="Year visible on coin") gregorian_year: int | None = Field(None, description="Gregorian calendar year") min_year: int | None = Field(None, description="First year of issuance (non-dated)") max_year: int | None = Field(None, description="Last year of issuance (non-dated)") mint_letter: str | None = Field(None, max_length=10, description="Mint mark") marks: list[Mark] = Field(default_factory=list, description="Mint/privy/die marks") signatures: list[Signature] = Field(default_factory=list, description="Signatures (banknotes)") mintage: int | None = Field(None, description="Number minted") references: list[Reference] = Field(default_factory=list, description="Catalogue references") comment: str | None = Field(None, description="Additional notes")
[docs] @classmethod def render_table(cls, items: list[Self], title: str = "") -> Table: """Generate table for issue list. Parameters ---------- items : list[Self] List of Issue instances title : str Table title Returns ------- Table Rich table with issue information """ table = Table(show_header=True, box=None, pad_edge=False, title=title) table.add_column("ID", no_wrap=True) table.add_column("Type ID", no_wrap=True) table.add_column("Year", no_wrap=True) table.add_column("Mint", no_wrap=True) table.add_column("Mintage", no_wrap=True, justify="right") table.add_column("Comment", no_wrap=False) for issue in items: # Display year or year range year_display = "" if issue.year: year_display = str(issue.year) elif issue.min_year and issue.max_year: year_display = f"{issue.min_year}-{issue.max_year}" elif issue.min_year: year_display = f"{issue.min_year}+" table.add_row( str(issue.numista_id), str(issue.type_id), year_display, issue.mint_letter or "", f"{issue.mintage:,}" if issue.mintage else "", issue.comment or "" ) return table
[docs] class IssueTerms(NumistaBaseModel): """Terms related to a coin issue. Parameters ---------- is_issued : bool Whether the issue was officially issued issue_date : date | None Official issue date (optional) Raises ------ ValidationError If required fields missing or invalid types Examples -------- >>> terms = IssueTerms( ... is_issued=True, ... issue_date=date(1973, 1, 1) ... ) >>> print(terms.is_issued) True """ is_issued: bool = Field(description="Whether the issue was officially issued") issue_date: date | None = Field(None, description="Official issue date")
[docs] @field_validator("issue_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 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 IssuingEntity(NumistaBaseModel): """Entity responsible for issuing a coin. Parameters ---------- id : int Unique identifier for the issuing entity name : str Name of the issuing entity wikidata_id : str | None Optional Wikidata identifier """ id: int = Field(alias="numista_id", description="Unique identifier for the issuing entity") name: str = Field(description="Name of the issuing entity") wikidata_id: str | None = Field(None, description="Wikidata identifier") @computed_field def wikidata_url(self) -> HttpUrl | None: """Computed Wikidata URL from `wikidata_id`. Returns ------- HttpUrl | None Full Wikidata URL if `wikidata_id` present, else None. """ if self.wikidata_id: return HttpUrl(f"https://www.wikidata.org/wiki/{self.wikidata_id}") return None