Source code for vairified.models

"""
Vairified SDK Models

Rich model classes with methods for easy API interaction.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Optional
from uuid import uuid4

if TYPE_CHECKING:
    from vairified.client import Vairified


[docs] @dataclass class RatingSplit: """ A single rating split with metadata. :ivar rating: The rating value. :ivar abbr: Abbreviation (e.g., "VG", "50+"). :ivar date_played: Date of last match in this category. """ rating: float abbr: str date_played: Optional[str] = None
[docs] @classmethod def from_dict(cls, data: dict) -> "RatingSplit": """ Create from API response dict. :param data: Rating split data from API. :returns: RatingSplit instance. """ rating_val = data.get("rating", 0) if isinstance(rating_val, str): rating_val = float(rating_val) if rating_val else 0.0 return cls( rating=rating_val, abbr=data.get("abbr", ""), date_played=data.get("date_played"), )
[docs] @dataclass class RatingSplits: """ Rating breakdown by category. Access ratings by category name (e.g., "open", "50_and_up") or use convenience properties for common categories. :ivar splits: Dict mapping category names to RatingSplit objects. """ splits: dict[str, RatingSplit] = field(default_factory=dict)
[docs] @classmethod def from_dict(cls, data: Optional[dict]) -> "RatingSplits": """ Create from API response dict. :param data: Rating splits data from API (nested or flat format). :returns: RatingSplits instance. """ if not data: return cls() splits = {} for key, value in data.items(): if isinstance(value, dict): # Nested format: { "open": { "rating": "4.25", "abbr": "OP" } } splits[key] = RatingSplit.from_dict(value) elif isinstance(value, (int, float)): # Flat format: { "VG": 4.25, "VM": 4.10 } splits[key] = RatingSplit(rating=float(value), abbr=key) return cls(splits=splits)
[docs] def get(self, category: str) -> Optional[float]: """ Get rating for a category. :param category: Category name (e.g., "open", "VG", "50_and_up"). :returns: Rating value or None if not found. """ split = self.splits.get(category) return split.rating if split else None
@property def open(self) -> Optional[float]: """Open division rating.""" return self.get("open") or self.get("VO") @property def gender(self) -> Optional[float]: """Gender-specific rating (same gender doubles).""" return self.get("gender") or self.get("VG") @property def mixed(self) -> Optional[float]: """Mixed doubles rating.""" return self.get("mixed") or self.get("VM") @property def recreational(self) -> Optional[float]: """Recreational rating.""" return self.get("recreational") or self.get("R") @property def singles(self) -> Optional[float]: """Singles rating.""" return self.get("singles") or self.get("S") @property def best(self) -> Optional[float]: """Best available verified rating.""" ratings = [s.rating for s in self.splits.values() if s.rating > 0] return max(ratings) if ratings else None
[docs] def to_dict(self) -> dict: """Convert to dict.""" return {k: {"rating": v.rating, "abbr": v.abbr} for k, v in self.splits.items()}
def __repr__(self) -> str: parts = [f"{k}={v.rating:.2f}" for k, v in self.splits.items() if v.rating > 0] return f"RatingSplits({', '.join(parts)})"
[docs] @dataclass class Player: """ A player in the Vairified system. From public search, only limited data is available (display name, location, rating). For full profile data, use :meth:`Vairified.get_member` with OAuth consent. :ivar id: External player ID (vair_mem_xxx format). :ivar display_name: Display name (First Name + Last Initial from search). :ivar first_name: Player's first name (only from connected member). :ivar last_name: Player's last name (only from connected member). :ivar rating: Primary/overall rating (2.0-8.0). :ivar is_vairified: Whether player is verified. :ivar is_connected: Whether player has connected to your app. :ivar rating_splits: Ratings by category (only from connected member). :ivar city: City. :ivar state: State code. :ivar country: Country code. """ id: str rating: float display_name: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None is_vairified: bool = False is_connected: bool = False rating_splits: RatingSplits = field(default_factory=RatingSplits) city: Optional[str] = None state: Optional[str] = None country: Optional[str] = None _client: Optional["Vairified"] = field(default=None, repr=False) @property def name(self) -> str: """Full name (or display name if full name not available).""" if self.first_name and self.last_name: return f"{self.first_name} {self.last_name}".strip() return self.display_name or "" @property def verified_rating(self) -> Optional[float]: """Best verified rating.""" return self.rating_splits.best
[docs] @classmethod def from_dict(cls, data: dict, client: Optional["Vairified"] = None) -> "Player": """ Create from API response dict. Handles both search (limited data) and member (full data) formats. :param data: Player data from API. :param client: Vairified client for back-reference. :returns: Player instance. """ # Search format uses displayName if "displayName" in data: # Public search format (limited data) return cls( id=data.get("id", ""), display_name=data.get("displayName", ""), rating=float(data.get("rating", 0.0)) if data.get("rating") else 0.0, is_vairified=data.get("isVairified", False), is_connected=data.get("isConnected", False), city=data.get("city"), state=data.get("state"), country=data.get("country"), _client=client, ) else: # Member format (full data) return cls( id=data.get("id", ""), first_name=data.get("firstName", ""), last_name=data.get("lastName", ""), rating=float(data.get("rating", 0.0)) if data.get("rating") else 0.0, is_vairified=data.get("isVairified", False), rating_splits=RatingSplits.from_dict(data.get("ratingSplits")), city=data.get("city"), state=data.get("state"), country=data.get("country"), _client=client, )
def __str__(self) -> str: verified = " ✓" if self.is_vairified else "" return f"{self.name} ({self.rating:.2f}){verified}"
[docs] @dataclass class Member(Player): """ A member with full profile access (requires OAuth connection). Extends :class:`Player` with additional profile data and methods. Only accessible for players who have connected their account via OAuth. :ivar email: Email address (only if profile:email scope granted). :ivar granted_scopes: List of scopes the player granted to your app. """ email: Optional[str] = None granted_scopes: list[str] = field(default_factory=list)
[docs] @classmethod def from_dict(cls, data: dict, client: Optional["Vairified"] = None) -> "Member": """ Create from API response dict. :param data: Member data from API. :param client: Vairified client for back-reference. :returns: Member instance. """ return cls( id=data.get("id", ""), first_name=data.get("firstName", ""), last_name=data.get("lastName", ""), email=data.get("email"), rating=float(data.get("rating", 0.0)) if data.get("rating") else 0.0, is_vairified=data.get("isVairified", False), rating_splits=RatingSplits.from_dict(data.get("ratingSplits")), city=data.get("city"), state=data.get("state"), country=data.get("country"), granted_scopes=data.get("grantedScopes", []), _client=client, )
[docs] def has_scope(self, scope: str) -> bool: """ Check if the player has granted a specific scope. :param scope: Scope to check (e.g., 'profile:email', 'match:submit'). :returns: True if scope is granted. """ return scope in self.granted_scopes
[docs] async def refresh(self) -> "Member": """ Refresh member data from API. :returns: Updated Member instance (self). :raises RuntimeError: If not connected to client. """ if not self._client: raise RuntimeError("Member not connected to client") updated = await self._client.get_member(self.id) # Update all fields for key, value in vars(updated).items(): if not key.startswith("_"): setattr(self, key, value) return self
[docs] @dataclass class Match: """ A match to submit to the Vairified Partner API. For doubles matches, provide two player IDs per team. For singles, provide one player ID per team. :ivar event: Event/tournament name (required). :ivar bracket: Bracket/division name (required). :ivar date: Match date and time (required). :ivar team1: Tuple of (player1_id, player2_id) or just (player1_id,) for singles. :ivar team2: Tuple of (player1_id, player2_id) or just (player1_id,) for singles. :ivar scores: List of (team1_score, team2_score) tuples for each game. :ivar match_type: Match type (e.g., "SIDEOUT", "RALLY"). :ivar source: Match source (e.g., "CLUB", "TOURNAMENT"). :ivar location: Match location (optional). :ivar identifier: Unique match identifier (auto-generated if not provided). Example:: # Doubles match: 11-9, 11-7 match = Match( event="Weekly League", bracket="4.0 Doubles", date=datetime.now(), team1=("player1_id", "player2_id"), team2=("player3_id", "player4_id"), scores=[(11, 9), (11, 7)], ) # Singles match: 11-8, 9-11, 11-6 match = Match( event="Club Singles", bracket="Open Singles", date=datetime.now(), team1=("player1_id",), team2=("player2_id",), scores=[(11, 8), (9, 11), (11, 6)], ) """ event: str bracket: str date: datetime team1: tuple[str, ...] team2: tuple[str, ...] scores: list[tuple[int, int]] match_type: str = "SIDEOUT" source: str = "PARTNER" location: Optional[str] = None identifier: Optional[str] = None id: Optional[str] = None def __post_init__(self): """Generate identifier if not provided.""" if not self.identifier: self.identifier = f"SDK-{uuid4().hex[:12]}" @property def format(self) -> str: """Match format: SINGLES or DOUBLES.""" return "SINGLES" if len(self.team1) == 1 else "DOUBLES" @property def winner(self) -> int: """ Team that won (1 or 2). :returns: 1 if team 1 won, 2 if team 2 won, 0 if tie. """ t1_wins = sum(1 for s1, s2 in self.scores if s1 > s2) t2_wins = sum(1 for s1, s2 in self.scores if s2 > s1) if t1_wins > t2_wins: return 1 elif t2_wins > t1_wins: return 2 return 0 @property def score_summary(self) -> str: """Score summary like '11-9, 11-7'.""" return ", ".join(f"{s1}-{s2}" for s1, s2 in self.scores)
[docs] def to_dict(self) -> dict[str, Any]: """ Convert to API request format. :returns: Dict matching the MatchInput DTO. """ # Build team objects with embedded scores team_a: dict[str, Any] = {"player1": self.team1[0]} team_b: dict[str, Any] = {"player1": self.team2[0]} if len(self.team1) > 1: team_a["player2"] = self.team1[1] if len(self.team2) > 1: team_b["player2"] = self.team2[1] # Add game scores to teams for i, (s1, s2) in enumerate(self.scores[:5], start=1): team_a[f"game{i}"] = s1 team_b[f"game{i}"] = s2 if isinstance(self.date, datetime): match_date = self.date.isoformat() else: match_date = self.date return { "identifier": self.identifier, "bracket": self.bracket, "event": self.event, "format": self.format, "matchDate": match_date, "matchSource": self.source, "matchType": self.match_type, "location": self.location, "teamA": team_a, "teamB": team_b, }
[docs] @dataclass class RatingUpdate: """ A rating change notification. :ivar id: External player ID (vair_mem_xxx format). :ivar member_name: Member name. :ivar previous_rating: Rating before change. :ivar new_rating: Rating after change. :ivar changed_at: When the change occurred. :ivar rating_splits: Updated rating splits. """ id: str previous_rating: float new_rating: float changed_at: datetime member_name: Optional[str] = None rating_splits: RatingSplits = field(default_factory=RatingSplits) _client: Optional["Vairified"] = field(default=None, repr=False) @property def change(self) -> float: """Amount of rating change.""" return self.new_rating - self.previous_rating @property def improved(self) -> bool: """Whether rating improved.""" return self.change > 0
[docs] @classmethod def from_dict( cls, data: dict, client: Optional["Vairified"] = None ) -> "RatingUpdate": """ Create from API response dict. :param data: Rating update data from API. :param client: Vairified client for back-reference. :returns: RatingUpdate instance. """ changed_at = data.get("changedAt") or data.get("updatedAt", "") if isinstance(changed_at, str) and changed_at: changed_at = datetime.fromisoformat(changed_at.replace("Z", "+00:00")) else: changed_at = datetime.now() return cls( id=data.get("id", data.get("memberId", data.get("playerId", ""))), member_name=data.get("memberName"), previous_rating=data.get("previousRating", 0.0), new_rating=data.get("newRating", 0.0), changed_at=changed_at, rating_splits=RatingSplits.from_dict(data.get("ratingSplits")), _client=client, )
[docs] async def get_member(self) -> Member: """ Fetch the member associated with this update. :returns: Member object. :raises RuntimeError: If not connected to client. """ if not self._client: raise RuntimeError("Update not connected to client") return await self._client.get_member(self.id)
def __str__(self) -> str: direction = "↑" if self.improved else "↓" name = f" ({self.member_name})" if self.member_name else "" prev, new = self.previous_rating, self.new_rating return f"{self.id}{name}: {prev:.2f} {direction} {new:.2f}"
[docs] @dataclass class MatchResult: """ Result of a match submission. :ivar success: Whether submission succeeded. :ivar num_matches: Number of matches processed. :ivar num_games: Number of games recorded. :ivar dry_run: Whether this was a dry-run (validation only). :ivar message: Human-readable result message. :ivar errors: List of validation/processing errors. """ success: bool num_matches: int num_games: int dry_run: bool = False message: Optional[str] = None errors: list[str] = field(default_factory=list)
[docs] @classmethod def from_dict(cls, data: dict) -> "MatchResult": """ Create from API response dict. :param data: Match result data from API. :returns: MatchResult instance. """ return cls( success=data.get("success", False), num_matches=data.get("numMatches", 0), num_games=data.get("numGames", 0), dry_run=data.get("dryRun", False), message=data.get("message"), errors=data.get("errors", []), )
@property def is_dry_run(self) -> bool: """Alias for dry_run.""" return self.dry_run def __bool__(self) -> bool: return self.success and not self.errors
[docs] @dataclass class SearchResults: """ Paginated search results. Supports iteration and async pagination. :ivar players: List of Player objects. :ivar total: Total matching players. :ivar page: Current page number. :ivar limit: Results per page. """ players: list[Player] total: int page: int limit: int _client: Optional["Vairified"] = field(default=None, repr=False) _filters: dict = field(default_factory=dict, repr=False) @property def has_more(self) -> bool: """Whether more results are available.""" return (self.page * self.limit) < self.total @property def pages(self) -> int: """Total number of pages.""" if self.limit <= 0: return 0 return (self.total + self.limit - 1) // self.limit def __iter__(self): return iter(self.players) def __len__(self) -> int: return len(self.players) def __getitem__(self, index: int) -> Player: return self.players[index] def __bool__(self) -> bool: return len(self.players) > 0
[docs] async def next_page(self) -> "SearchResults": """ Fetch next page of results. :returns: SearchResults for the next page. :raises RuntimeError: If not connected to client. :raises StopIteration: If no more pages. """ if not self._client: raise RuntimeError("Results not connected to client") if not self.has_more: raise StopIteration("No more pages") filters = self._filters.copy() filters["page"] = self.page + 1 return await self._client.search(**filters)
[docs] @classmethod def from_dict( cls, data: dict, client: Optional["Vairified"] = None, filters: Optional[dict] = None, ) -> "SearchResults": """ Create from API response dict. :param data: Search results data from API. :param client: Vairified client for back-reference. :param filters: Original search filters for pagination. :returns: SearchResults instance. """ players = [Player.from_dict(p, client) for p in data.get("players", [])] return cls( players=players, total=data.get("total", len(players)), page=data.get("page", 1), limit=data.get("limit", 20), _client=client, _filters=filters or {}, )