Source code for animedex.models.common

"""
Ground-floor types every animedex backend, model, and renderer depends on.

This module owns the types that participate in the source-attribution
contract from ``plans/03-cli-architecture-gh-flavored.md`` (the
:class:`SourceTag` carrier; :class:`AnimedexModel` base) and the
small typed surface the library uses to talk about HTTP-shaped facts
(:class:`Pagination`, :class:`RateLimit`, :class:`ApiError`).

The pydantic v2 base class :class:`AnimedexModel` fixes a single,
project-wide configuration: models are immutable (``frozen=True``),
silently ignore unknown upstream fields (``extra='ignore'``), and
accept both alias and field name on input (``populate_by_name=True``).
This is enforced once here so individual backend modules do not drift.

The module also exposes a :func:`selftest` callable the diagnostic
runner picks up; it instantiates representative samples of every type
to confirm the schema parses end-to-end.
"""

from __future__ import annotations

from datetime import datetime, timezone
from typing import Optional

from pydantic import BaseModel, ConfigDict


[docs] class AnimedexModel(BaseModel): """Project-wide pydantic v2 base for **common projection** types. Common types (e.g. :class:`~animedex.models.anime.Anime`, :class:`~animedex.models.character.Character`, :class:`~animedex.models.character.Staff`, :class:`~animedex.models.character.Studio`) are deliberately the "lowest common denominator" the project surfaces across multiple upstreams. They drop backend-specific fields by design — that is the whole point of a projection. ``extra='ignore'`` enforces this policy: any field a backend gives us that the projection didn't declare is dropped silently. Backend-specific **rich** types (``AnilistAnime``, ``JikanAnime``, etc.) are NOT this. See :class:`BackendRichModel` below — they must be lossless. :ivar model_config: pydantic configuration; do not override per subclass without a documented reason. """ model_config = ConfigDict( frozen=True, extra="ignore", populate_by_name=True, )
[docs] class BackendRichModel(AnimedexModel): """Project-wide pydantic v2 base for **backend rich** dataclasses. Every per-backend rich type — ``AnilistAnime``, ``AnilistCharacter``, ``AnilistStaff``, ``AnilistStudio``, the long-tail Anilist* types, ``JikanAnime``, ``JikanCharacter``, ``JikanPerson``, ``JikanProducer``, ``JikanClub``, ``JikanUser``, ``JikanGenericRow``, ``RawTraceHit``, ``RawTraceQuota``, plus their nested helper types (e.g. ``_AnilistTitle``, ``JikanEntity``, ``JikanAired``) — inherits from this class. The contract: a rich model is **information-lossless**. When fed the raw upstream payload via ``model_validate``, it must retain every key the upstream returned, so ``model_dump(by_alias=True, mode='json')`` reconstructs the original payload field-for-field. Achieved by: * ``extra='allow'`` — fields the model didn't declare are kept on the instance and re-emitted on dump (instead of silently dropped, which is what :class:`AnimedexModel` does for common projections). * ``populate_by_name=True`` — alias-renamed fields (e.g. ``from_: float = Field(alias='from')`` for the Python keyword conflict) accept either form on input. The class **inherits from** :class:`AnimedexModel` rather than sitting beside it. Pydantic v2 merges ``model_config`` across inheritance, so the ``extra='allow'`` here overrides the parent's ``extra='ignore'``. The inheritance matters: every ``isinstance(x, AnimedexModel)`` check downstream (the TTY dispatcher in :mod:`animedex.render.tty`, the JSON renderer, the CLI's ``_to_tty_text`` helper) must continue to recognise rich instances. Splitting the two roots was a bug that made rich models fall through to ``str(x)`` and dump pydantic ``__repr__`` instead of human-friendly TTY text. Backend-rich → common projection happens via the rich type's ``to_common()`` method. Loss of fields is permitted at and only at that boundary. """ model_config = ConfigDict( frozen=True, extra="allow", populate_by_name=True, )
[docs] class SourceTag(AnimedexModel): """Provenance carrier attached to every backend-returned record. Per ``plans/03-cli-architecture-gh-flavored.md`` §5, every datum surfaced by animedex must carry the upstream that produced it. :class:`SourceTag` is the typed form of that contract: the TTY renderer prints ``[src: <backend>]`` from it, the JSON renderer emits ``_source`` from it, and the cache layer records it. :ivar backend: Short upstream identifier (e.g. ``"anilist"``, ``"jikan"``, ``"kitsu"``). :vartype backend: str :ivar fetched_at: When this datum was retrieved (or, for a cache hit, when it was originally retrieved). :vartype fetched_at: datetime.datetime :ivar cached: ``True`` when the value was served from local cache rather than a fresh upstream call. :vartype cached: bool :ivar rate_limited: ``True`` when fetching this datum required waiting for a rate-limit window. :vartype rate_limited: bool """ backend: str fetched_at: datetime cached: bool = False rate_limited: bool = False
[docs] class PartialDate(AnimedexModel): """A date where any of year/month/day may be unknown. AniList's ``dateOfBirth`` / ``startDate`` / ``endDate`` shapes return ``{ year, month, day }`` with each component independently nullable. A character may have a known birth-month but no day; a series may be year-only when the exact air date isn't recorded. :class:`datetime.date` cannot represent that, so this lighter- weight type stands in. :ivar year: Calendar year when known. :vartype year: int or None :ivar month: Calendar month (1-12) when known. :vartype month: int or None :ivar day: Day of month (1-31) when known. :vartype day: int or None """ year: Optional[int] = None month: Optional[int] = None day: Optional[int] = None
[docs] class RateLimit(AnimedexModel): """Snapshot of an upstream's rate-limit posture for a single call. :ivar remaining: Calls remaining in the current window when reported. :vartype remaining: int or None :ivar reset_at: When the current window resets. :vartype reset_at: datetime.datetime """ remaining: Optional[int] = None reset_at: datetime
#: The complete vocabulary of :class:`ApiError` ``reason`` slugs. #: Library callers that want to branch on a typed error can match #: against this set; new sites that emit :class:`ApiError` should #: pick one of these values rather than inventing a new slug, so the #: vocabulary stays stable for downstream consumers. ``ApiError``'s #: ``__init__`` validates ``reason`` against this set so a typo #: surfaces at construction time rather than at error-handling time. REASONS = frozenset( { # caller-side "auth-required", "bad-args", # transport / firewall "firewall", "read-only", "unknown-backend", # upstream-side, rough cause known "rate-limited", "upstream-error", "upstream-decode", "upstream-shape", "graphql-error", "not-found", # render / shell-out "jq-failed", "jq-missing", "unknown-field", # internal "malformed-guidance", "selftest", } )
[docs] class ApiError(Exception): """Typed error raised by the transport, cache, and policy layers. Carries a structured ``backend`` and ``reason`` so the CLI can render a stable, machine-grepable message without parsing free text. The string form of the exception always includes both fields when set. :param message: Human-readable description of the failure. :type message: str :param backend: Backend identifier when the failure is backend-specific. :type backend: str or None :param reason: Short slug categorising the failure. Must be one of the values in :data:`REASONS`. The full vocabulary is fixed by the project (so library consumers can branch on a known set); a typo at construction time raises :class:`ValueError`. :type reason: str or None :raises ValueError: When ``reason`` is set but not in :data:`REASONS`. """
[docs] def __init__( self, message: str, *, backend: Optional[str] = None, reason: Optional[str] = None, ) -> None: if reason is not None and reason not in REASONS: raise ValueError( f"unknown ApiError reason: {reason!r}. " f"Add it to animedex.models.common.REASONS or use one " f"of: {sorted(REASONS)!r}" ) super().__init__(message) self.message = message self.backend = backend self.reason = reason
[docs] def __str__(self) -> str: prefix_bits = [] if self.backend is not None: prefix_bits.append(f"backend={self.backend}") if self.reason is not None: prefix_bits.append(f"reason={self.reason}") if prefix_bits: return f"[{' '.join(prefix_bits)}] {self.message}" return self.message
[docs] def require_field(row: dict, key: str, *, backend: str, what: str): """Return ``row[key]`` or raise :class:`ApiError` (``upstream-shape``). Used inside mapper code where a particular field is *expected* to be populated on every row (e.g. ``id`` on a search-result row, or ``priority`` on the ``/me`` envelope). A plain ``row[key]`` would crash with :class:`KeyError` and leak the failure point as an internal exception type; this helper converts that to a typed error the dispatcher / CLI / library callers know how to catch. Surfaces upstream schema drift as ``reason='upstream-shape'`` rather than as a Python exception class. :param row: One dict from an upstream response (a list-row or a single-entity body). :param key: The required field name on the row. :param backend: Backend identifier — appears on the raised error. :param what: Short label for the row shape (e.g. ``"review"``, ``"/me"``) — appears in the error message. :raises ApiError: When ``key`` is absent from ``row``. ``reason='upstream-shape'``. """ if key not in row: raise ApiError( f"{backend} {what} row missing required field {key!r}", backend=backend, reason="upstream-shape", ) return row[key]
[docs] def selftest() -> bool: """Smoke-test the types end-to-end. The diagnostic runner (:func:`animedex.diag.run_selftest`) invokes this; the function instantiates each public type so that pydantic schema construction is exercised. Anything that would fail at first use - missing field, wrong default, broken alias - surfaces here in milliseconds, before the CLI tries to render a real backend response. :return: ``True`` when the model graph parses. Raises on schema errors so the runner reports them with a traceback. :rtype: bool """ now = datetime.now(timezone.utc) SourceTag(backend="_selftest", fetched_at=now) SourceTag.model_validate({"backend": "_selftest", "fetched_at": now.isoformat()}) PartialDate(year=2026, month=5, day=7) PartialDate(year=2026) PartialDate() Pagination(page=1, per_page=20, has_next=False) RateLimit(reset_at=now) err = ApiError("smoke", backend="_selftest", reason="selftest") str(err) # AnimedexModel and Any are re-exports; importing them at the # top of the module is sufficient confirmation that the public # surface loads. return True