Source code for vairified.models

"""
Vairified SDK Models — Partner API v1 shapes.

All response models are :class:`pydantic.BaseModel` with
``model_config = ConfigDict(frozen=True, populate_by_name=True, extra="allow")``
so they're immutable, support both snake_case (Python) and camelCase (wire)
field names, and tolerate new server-side fields without breaking.

The public surface is designed to feel native:

* ``member.name`` is a :func:`property`, not a method — no ``get_name()``.
* ``member.sport["pickleball"]`` is dict-like access; ``SportRating``
  implements ``__getitem__``, ``__iter__``, ``__contains__``, ``__len__``.
* Every model has a human-readable ``__repr__`` so the REPL is useful.
* Models work with :keyword:`match` statements via pydantic field access.

Breaking from v0.1.x:
    The flat single-sport response (``member.rating`` / ``member.rating_splits``)
    has been replaced by a multi-sport ``member.sport`` dict keyed by sport code.
    The :class:`Match` class takes ``teams: list[list[str]]`` and
    ``games: list[Game]`` instead of ``team1/team2`` plus per-game tuples.
"""

from __future__ import annotations

from collections.abc import Iterator
from enum import StrEnum
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field

# ---------------------------------------------------------------------------
# Response config — shared by every read-side model.
# ---------------------------------------------------------------------------

_RESPONSE_CONFIG = ConfigDict(
    frozen=True,
    populate_by_name=True,
    extra="allow",
)


# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


[docs] class Gender(StrEnum): """ Normalized gender enum returned by the Partner API. Matches the UPPERCASE tokens emitted by ``PartnerMember.gender`` on the backend. """ MALE = "MALE" FEMALE = "FEMALE" OTHER = "OTHER" UNKNOWN = "UNKNOWN"
# --------------------------------------------------------------------------- # Rating splits + sport ratings # ---------------------------------------------------------------------------
[docs] class RatingSplit(BaseModel): """ One slice of a player's rating for a specific category × age bracket. Keys in :attr:`SportRating.rating_splits` are strings like ``"overall-open"``, ``"singles-12-13"``, or ``"overall-40+"``. """ model_config = _RESPONSE_CONFIG rating: float abbr: str def __repr__(self) -> str: # pragma: no cover - REPL affordance return f"<RatingSplit {self.rating:.3f} {self.abbr}>"
[docs] class SportRating(BaseModel): """ A player's ratings for a single sport. The top-level ``rating`` / ``abbr`` is the primary rating for that sport (conventionally the overall-open bracket). Every category × age bracket the player has played is also available under :attr:`rating_splits`, keyed by ``{category}-{bracketCode}``. This class is dict-like — you can access splits by subscript, iterate them, check membership, and get the length without touching ``rating_splits`` directly:: overall = member.sport["pickleball"]["overall-open"].rating for key, split in member.sport["pickleball"]: print(key, split.rating) if "singles-40+" in member.sport["pickleball"]: ... print(len(member.sport["pickleball"]), "splits") """ model_config = _RESPONSE_CONFIG rating: float abbr: str rating_splits: dict[str, RatingSplit] = Field( default_factory=dict, alias="ratingSplits" ) # Per-sport status (Vairified#783). VAIRification and VAIR-Pro certs are # sport-scoped, so these live on each sport rather than the member status. # Defaulted so the SDK stays compatible with API responses that predate #783. is_vairified: bool = Field(default=False, alias="isVairified") """Player is VAIRified in this sport (has a verified, non-recreational rating).""" is_rater: bool = Field(default=False, alias="isRater") """Active VAIR Pro (can rate) in this sport. Alias of :attr:`is_vair_pro`.""" is_vair_pro: bool = Field(default=False, alias="isVairPro") """Player is an active VAIR Pro (can rate) in this sport.""" is_vair_pro_status: Literal["PENDING", "ACTIVE"] | None = Field( default=None, alias="isVairProStatus" ) """VAIR-Pro lifecycle status here: ``"ACTIVE"``, ``"PENDING"``, or ``None``.""" def __getitem__(self, key: str) -> RatingSplit: return self.rating_splits[key] def __iter__(self) -> Iterator[tuple[str, RatingSplit]]: # type: ignore[override] return iter(self.rating_splits.items()) def __contains__(self, key: object) -> bool: return key in self.rating_splits def __len__(self) -> int: return len(self.rating_splits)
[docs] def keys(self) -> Any: """Split keys (e.g. ``"overall-open"``, ``"singles-12-13"``).""" return self.rating_splits.keys()
[docs] def get(self, key: str, default: RatingSplit | None = None) -> RatingSplit | None: """Dict-style safe lookup.""" return self.rating_splits.get(key, default)
def __repr__(self) -> str: # pragma: no cover return ( f"<SportRating {self.rating:.3f} {self.abbr} " f"splits={len(self.rating_splits)}>" )
# --------------------------------------------------------------------------- # Member / player # ---------------------------------------------------------------------------
[docs] class MemberStatus(BaseModel): """ Global status flags for a player. Grouped into a sub-object rather than top-level booleans so that inspection (``pprint``, ``repr``, JSON) keeps all ``is_*`` flags visually clustered. Only the genuinely global flags live here. VAIRification and VAIR-Pro status are per-sport (Vairified#783) and live on each :class:`SportRating` (``member.sport["pickleball"].is_vairified``). """ model_config = _RESPONSE_CONFIG is_wheelchair: bool = Field(alias="isWheelchair") is_ambassador: bool = Field(alias="isAmbassador") is_connected: bool = Field(alias="isConnected") def __repr__(self) -> str: # pragma: no cover flags = [ name for name, value in ( ("wheelchair", self.is_wheelchair), ("ambassador", self.is_ambassador), ("connected", self.is_connected), ) if value ] return f"<MemberStatus {' '.join(flags) or '(none)'}>"
[docs] class Member(BaseModel): """ A partner-facing player record. Returned by :meth:`Vairified.members.get` (full detail, requires an active OAuth connection) and :meth:`Vairified.members.search` (limited detail for public search). Rating data lives under :attr:`sport` — a dict keyed by sport code. The backend returns only the sports the player has ratings in, or only the sports requested via the ``?sport=`` query filter. Use :meth:`rating_for` to fetch the primary rating for a specific sport with a sensible default. """ model_config = _RESPONSE_CONFIG member_id: int = Field(alias="memberId") id: str | None = None first_name: str = Field(alias="firstName") last_name: str = Field(alias="lastName") full_name: str = Field(alias="fullName") display_name: str = Field(alias="displayName") age: int | None = None city: str | None = None state: str | None = None zip: str | None = None country: str | None = None gender: Gender | None = None status: MemberStatus sport: dict[str, SportRating] = Field(default_factory=dict) active_leagues: list[str] | None = Field(default=None, alias="activeLeagues") email: str | None = None granted_scopes: list[str] | None = Field(default=None, alias="grantedScopes") # ---- Convenience properties ---- @property def name(self) -> str: """Full name — alias for :attr:`full_name`, matching common usage.""" return self.full_name @property def sports(self) -> list[str]: """The list of sport codes this player has ratings in.""" return list(self.sport.keys())
[docs] def rating_for(self, sport: str = "pickleball") -> float | None: """ Primary rating for a given sport. :param sport: Sport code, defaults to ``"pickleball"``. :returns: The primary rating value, or ``None`` if the player has no ratings for that sport. Example:: member.rating_for() # pickleball member.rating_for("padel") # padel """ sport_rating = self.sport.get(sport) return sport_rating.rating if sport_rating else None
[docs] def split( self, key: str, sport: str = "pickleball", ) -> RatingSplit | None: """ Get a specific rating split for a sport. :param key: Split key, e.g. ``"overall-open"`` or ``"singles-12-13"``. :param sport: Sport code, defaults to ``"pickleball"``. """ sport_rating = self.sport.get(sport) if sport_rating is None: return None return sport_rating.get(key)
def __repr__(self) -> str: # pragma: no cover primary = next(iter(self.sport.values()), None) if primary: return ( f"<Member #{self.member_id} '{self.display_name}' " f"rating={primary.rating:.3f} {primary.abbr}>" ) return f"<Member #{self.member_id} '{self.display_name}'>"
# --------------------------------------------------------------------------- # Rating updates (webhook / polling) # ---------------------------------------------------------------------------
[docs] class RatingUpdate(BaseModel): """ A single rating change notification. Returned by :meth:`Vairified.members.rating_updates` (polling) and delivered via webhook callbacks to partners that have registered a webhook URL. """ model_config = _RESPONSE_CONFIG member_id: int = Field(alias="memberId") id: str | None = None display_name: str | None = Field(default=None, alias="displayName") sport: str | None = None previous_rating: float | None = Field(default=None, alias="previousRating") new_rating: float | None = Field(default=None, alias="newRating") changed_at: str | None = Field(default=None, alias="changedAt") rating_splits: dict[str, RatingSplit] | None = Field( default=None, alias="ratingSplits" ) @property def delta(self) -> float | None: """Rating change amount. ``None`` when either rating is missing.""" if self.previous_rating is None or self.new_rating is None: return None return self.new_rating - self.previous_rating @property def improved(self) -> bool: """True when the new rating is strictly higher than the previous.""" delta = self.delta return delta is not None and delta > 0 def __repr__(self) -> str: # pragma: no cover arrow = "↑" if self.improved else "↓" prev = ( f"{self.previous_rating:.3f}" if self.previous_rating is not None else "?" ) new = f"{self.new_rating:.3f}" if self.new_rating is not None else "?" name = f" '{self.display_name}'" if self.display_name else "" return f"<RatingUpdate #{self.member_id}{name} {prev} {arrow} {new}>"
# --------------------------------------------------------------------------- # Match submission — request side (input models) # --------------------------------------------------------------------------- _REQUEST_CONFIG = ConfigDict(populate_by_name=True, extra="forbid")
[docs] class Game(BaseModel): """ One scored game within a :class:`Match`. ``scores`` is one integer per team, in the same order as the parent match's ``teams`` list. For a standard 2-team game ``scores`` is ``[team1_score, team2_score]``. The API supports n-team matches by setting a longer list. All fields except ``scores`` are optional overrides of the parent match's defaults — use them only when a specific game inside the match differs from the rest (e.g. a championship game played to 15 when the rest of the match was to 11). """ model_config = _REQUEST_CONFIG scores: list[int] identifier: str | None = None win_score: int | None = Field(default=None, alias="winScore") win_by: int | None = Field(default=None, alias="winBy")
[docs] class Match(BaseModel): """ One match to submit in a :class:`MatchBatch`. A match has: * ``teams`` — a list of teams, each a list of player IDs (external ``vair_mem_xxx``, numeric member IDs, or UUIDs). Supports n-team × n-player matches natively: ``[[p1, p2], [p3, p4]]`` for standard doubles, ``[[p1], [p2]]`` for singles, ``[[p1], [p2], [p3]]`` for a 3-way round robin. * ``games`` — one or more scored games (e.g. best-of-3 has 2 or 3 entries). Scores in each game are parallel to the ``teams`` order. Every other field is an optional override of the parent :class:`MatchBatch` default. """ model_config = _REQUEST_CONFIG identifier: str teams: list[list[str]] games: list[Game] # Optional per-match overrides of batch-level defaults sport: str | None = None bracket: str | None = None event: str | None = None location: str | None = None match_date: str | None = Field(default=None, alias="matchDate") match_source: str | None = Field(default=None, alias="matchSource") match_type: str | None = Field(default=None, alias="matchType") win_score: int | None = Field(default=None, alias="winScore") win_by: int | None = Field(default=None, alias="winBy") extras: dict[str, Any] | None = None original_id: str | None = Field(default=None, alias="originalId") original_type: str | None = Field(default=None, alias="originalType") club_id: int | None = Field(default=None, alias="clubId") @property def num_games(self) -> int: """Number of scored games in this match (best-of-N count).""" return len(self.games) @property def num_teams(self) -> int: """Number of teams in this match.""" return len(self.teams) def __repr__(self) -> str: # pragma: no cover shape = "×".join(str(len(t)) for t in self.teams) return f"<Match {self.identifier!r} teams={shape} games={self.num_games}>"
[docs] class MatchBatch(BaseModel): """ Compressed bulk match submission. Top-level fields are defaults applied to every match in the :attr:`matches` list. Any match can override any field. ``sport``, ``win_score``, and ``win_by`` are **required** at the batch level — partners must tell the rater which sport the matches are in and what the winning conditions were so scores can be interpreted correctly. Example:: batch = MatchBatch( sport="pickleball", win_score=11, win_by=2, bracket="4.0 Doubles", event="Weekly League", match_date="2026-04-11T14:00:00Z", matches=[ Match( identifier="m1", teams=[["vair_mem_aaa", "vair_mem_bbb"], ["vair_mem_ccc", "vair_mem_ddd"]], games=[Game(scores=[11, 8]), Game(scores=[11, 5])], ), ], ) result = await client.matches.submit(batch) """ model_config = _REQUEST_CONFIG sport: str win_score: int = Field(alias="winScore") win_by: int = Field(alias="winBy") matches: list[Match] # Optional batch-level defaults inherited by every match bracket: str | None = None event: str | None = None location: str | None = None match_date: str | None = Field(default=None, alias="matchDate") match_source: str | None = Field(default=None, alias="matchSource") match_type: str | None = Field(default=None, alias="matchType") extras: dict[str, Any] | None = None identifier: str | None = None original_id: str | None = Field(default=None, alias="originalId") original_type: str | None = Field(default=None, alias="originalType") club_id: int | None = Field(default=None, alias="clubId") dry_run: bool | None = Field(default=None, alias="dryRun") def __repr__(self) -> str: # pragma: no cover total_games = sum(m.num_games for m in self.matches) return ( f"<MatchBatch sport={self.sport!r} " f"matches={len(self.matches)} games={total_games}>" )
# --------------------------------------------------------------------------- # Match submission — response side # ---------------------------------------------------------------------------
[docs] class MatchBatchResult(BaseModel): """ Result of a :meth:`Vairified.matches.submit` call. ``success`` is ``True`` only when every match in the batch was accepted. Check :attr:`errors` for per-match validation failures. """ model_config = _RESPONSE_CONFIG success: bool num_matches: int = Field(alias="numMatches") num_games: int = Field(alias="numGames") dry_run: bool | None = Field(default=None, alias="dryRun") message: str | None = None errors: list[str] | None = None @property def ok(self) -> bool: """Shorthand: successful submission with zero errors.""" return self.success and not self.errors @property def is_dry_run(self) -> bool: """Whether this was a dry-run (validation only, nothing persisted).""" return bool(self.dry_run) def __repr__(self) -> str: # pragma: no cover mode = " [dry-run]" if self.dry_run else "" errs = f" errors={len(self.errors)}" if self.errors else "" return ( f"<MatchBatchResult {'ok' if self.ok else 'FAILED'}{mode} " f"matches={self.num_matches} games={self.num_games}{errs}>" )
# --------------------------------------------------------------------------- # Search filters (request-side) # ---------------------------------------------------------------------------
[docs] class SearchFilters(BaseModel): """ Filters accepted by :meth:`Vairified.members.search`. Most users won't construct this directly — the ``search()`` method accepts keyword arguments and builds it internally. But it's exposed so you can inspect the full set of available filters in one place. """ model_config = _REQUEST_CONFIG # Multi-sport filter — comma-separated list of sport codes. When # omitted, the server returns every sport each player has ratings in. sport: str | None = None # Name / ID — partial match on first/last name, or exact numeric memberId member: str | None = None # Location filters location: str | None = None country: str | None = None city: str | None = None state: str | None = None zip: str | None = None # Age filters age_filter_type: str | None = Field(default=None, alias="ageFilterType") age1: int | None = None age2: int | None = None # Gender + verified gender: str | None = None wheelchair: bool | None = None vairified: bool | None = None # Rating range rating1: float | None = None rating2: float | None = None # Sort + pagination sort_field: str | None = Field(default=None, alias="sortField") sort_direction: str | None = Field(default=None, alias="sortDirection") offset: int | None = None limit: int | None = None
[docs] def to_query_params(self) -> dict[str, Any]: """Serialize to the wire-format dict expected by httpx params=.""" return self.model_dump(by_alias=True, exclude_none=True)
# --------------------------------------------------------------------------- # Tournament import — response side # ---------------------------------------------------------------------------
[docs] class TournamentImportResult(BaseModel): """Result of a tournament import submission.""" model_config = _RESPONSE_CONFIG success: bool matches_imported: int = Field(alias="matchesImported") games_recorded: int = Field(alias="gamesRecorded") ghost_players_created: int = Field(alias="ghostPlayersCreated") existing_players_matched: int = Field(alias="existingPlayersMatched") dry_run: bool | None = Field(default=None, alias="dryRun") message: str | None = None errors: list[str] | None = None
# --------------------------------------------------------------------------- # Webhook deliveries — response side # ---------------------------------------------------------------------------
[docs] class WebhookDelivery(BaseModel): """A single webhook delivery attempt.""" model_config = _RESPONSE_CONFIG id: str event: str url: str status_code: int | None = Field(default=None, alias="statusCode") response_body: str | None = Field(default=None, alias="responseBody") error_message: str | None = Field(default=None, alias="errorMessage") attempts: int max_attempts: int = Field(alias="maxAttempts") last_attempt_at: str = Field(alias="lastAttemptAt") next_retry_at: str | None = Field(default=None, alias="nextRetryAt") completed_at: str | None = Field(default=None, alias="completedAt") created_at: str = Field(alias="createdAt") payload: dict[str, Any]
[docs] class WebhookDeliveriesResult(BaseModel): """Paginated list of webhook delivery attempts.""" model_config = _RESPONSE_CONFIG deliveries: list[WebhookDelivery] total: int
__all__ = [ "Gender", "Game", "Match", "MatchBatch", "MatchBatchResult", "Member", "MemberStatus", "RatingSplit", "RatingUpdate", "SearchFilters", "SportRating", "TournamentImportResult", "WebhookDelivery", "WebhookDeliveriesResult", ]