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 SourceTag carrier; AnimedexModel base) and the small typed surface the library uses to talk about HTTP-shaped facts (Pagination, RateLimit, ApiError).

The pydantic v2 base 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 selftest() callable the diagnostic runner picks up; it instantiates representative samples of every type to confirm the schema parses end-to-end.

REASONS

animedex.models.common.REASONS = frozenset({'auth-required', 'bad-args', 'firewall', 'graphql-error', 'jq-failed', 'jq-missing', 'malformed-guidance', 'not-found', 'rate-limited', 'read-only', 'selftest', 'unknown-backend', 'unknown-field', 'upstream-decode', 'upstream-error', 'upstream-shape'})

The complete vocabulary of ApiError reason slugs. Library callers that want to branch on a typed error can match against this set; new sites that emit 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.

AnimedexModel

class animedex.models.common.AnimedexModel[source]

Bases: BaseModel

Project-wide pydantic v2 base for common projection types.

Common types (e.g. Anime, Character, Staff, 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 BackendRichModel below — they must be lossless.

Variables:

model_config – pydantic configuration; do not override per subclass without a documented reason.

model_config: ClassVar[ConfigDict] = {'extra': 'ignore', 'frozen': True, 'populate_by_name': True, 'validate_by_alias': True, 'validate_by_name': True}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

BackendRichModel

class animedex.models.common.BackendRichModel(**extra_data: Any)[source]

Bases: 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 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 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 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: ClassVar[ConfigDict] = {'extra': 'allow', 'frozen': True, 'populate_by_name': True, 'validate_by_alias': True, 'validate_by_name': True}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

SourceTag

class animedex.models.common.SourceTag(*, backend: str, fetched_at: datetime, cached: bool = False, rate_limited: bool = False)[source]

Bases: 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. 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.

Variables:
  • backend (str) – Short upstream identifier (e.g. "anilist", "jikan", "kitsu").

  • fetched_at (datetime.datetime) – When this datum was retrieved (or, for a cache hit, when it was originally retrieved).

  • cached (bool) – True when the value was served from local cache rather than a fresh upstream call.

  • rate_limited (bool) – True when fetching this datum required waiting for a rate-limit window.

backend: str
fetched_at: datetime
cached: bool
rate_limited: bool

PartialDate

class animedex.models.common.PartialDate(*, year: int | None = None, month: int | None = None, day: int | None = None)[source]

Bases: 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. datetime.date cannot represent that, so this lighter- weight type stands in.

Variables:
  • year (int or None) – Calendar year when known.

  • month (int or None) – Calendar month (1-12) when known.

  • day (int or None) – Day of month (1-31) when known.

year: int | None
month: int | None
day: int | None

Pagination

class animedex.models.common.Pagination(*, page: int, per_page: int, total: int | None = None, has_next: bool = False)[source]

Bases: AnimedexModel

Page cursor accompanying list responses.

Variables:
  • page (int) – Current page index, 1-based.

  • per_page (int) – Items returned per page.

  • total (int or None) – Total item count when the upstream exposes it.

  • has_next (bool) – Whether another page is available.

page: int
per_page: int
total: int | None
has_next: bool

RateLimit

class animedex.models.common.RateLimit(*, remaining: int | None = None, reset_at: datetime)[source]

Bases: AnimedexModel

Snapshot of an upstream’s rate-limit posture for a single call.

Variables:
  • remaining (int or None) – Calls remaining in the current window when reported.

  • reset_at (datetime.datetime) – When the current window resets.

remaining: int | None
reset_at: datetime

ApiError

class animedex.models.common.ApiError(message: str, *, backend: str | None = None, reason: str | None = None)[source]

Bases: 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.

Parameters:
  • message (str) – Human-readable description of the failure.

  • backend (str or None) – Backend identifier when the failure is backend-specific.

  • reason (str or None) – Short slug categorising the failure. Must be one of the values in REASONS. The full vocabulary is fixed by the project (so library consumers can branch on a known set); a typo at construction time raises ValueError.

Raises:

ValueError – When reason is set but not in REASONS.

__init__(message: str, *, backend: str | None = None, reason: str | None = None) None[source]
__str__() str[source]

Return str(self).

require_field

animedex.models.common.require_field(row: dict, key: str, *, backend: str, what: str)[source]

Return row[key] or raise 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 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.

Parameters:
  • row – One dict from an upstream response (a list-row or a single-entity body).

  • key – The required field name on the row.

  • backend – Backend identifier — appears on the raised error.

  • 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'.

selftest

animedex.models.common.selftest() bool[source]

Smoke-test the types end-to-end.

The diagnostic runner (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.

Returns:

True when the model graph parses. Raises on schema errors so the runner reports them with a traceback.

Return type:

bool