This commit is contained in:
Trey t
2026-01-20 11:16:20 -06:00
parent 11adfc10dd
commit fbfdf136ae
15 changed files with 539 additions and 97064 deletions

View File

@@ -11,6 +11,15 @@ from .aliases import (
FuzzyMatch,
ManualReviewItem,
)
from .sport import (
Sport,
LeagueStructure,
LeagueStructureType,
save_sports,
load_sports,
save_league_structures,
load_league_structures,
)
__all__ = [
# Game
@@ -32,4 +41,12 @@ __all__ = [
"StadiumAlias",
"FuzzyMatch",
"ManualReviewItem",
# Sport and League Structure
"Sport",
"LeagueStructure",
"LeagueStructureType",
"save_sports",
"load_sports",
"save_league_structures",
"load_league_structures",
]

View File

@@ -0,0 +1,157 @@
"""Sport and LeagueStructure data models for sportstime-parser."""
from dataclasses import dataclass
from enum import Enum
from typing import Optional
import json
class LeagueStructureType(str, Enum):
"""Type of league structure element."""
CONFERENCE = "conference"
DIVISION = "division"
LEAGUE = "league"
@dataclass
class Sport:
"""Represents a sport with all CloudKit fields.
Attributes:
id: Canonical sport ID (e.g., 'MLB', 'NBA')
abbreviation: Sport abbreviation (e.g., 'MLB', 'NBA')
display_name: Full display name (e.g., 'Major League Baseball')
icon_name: SF Symbol name for the sport icon
color_hex: Primary color as hex string (e.g., '#FF0000')
season_start_month: Month number when season typically starts (1-12)
season_end_month: Month number when season typically ends (1-12)
is_active: Whether the sport is currently active/supported
"""
id: str
abbreviation: str
display_name: str
icon_name: str
color_hex: str
season_start_month: int
season_end_month: int
is_active: bool = True
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"id": self.id,
"abbreviation": self.abbreviation,
"display_name": self.display_name,
"icon_name": self.icon_name,
"color_hex": self.color_hex,
"season_start_month": self.season_start_month,
"season_end_month": self.season_end_month,
"is_active": self.is_active,
}
@classmethod
def from_dict(cls, data: dict) -> "Sport":
"""Create a Sport from a dictionary."""
return cls(
id=data["id"],
abbreviation=data["abbreviation"],
display_name=data["display_name"],
icon_name=data["icon_name"],
color_hex=data["color_hex"],
season_start_month=data["season_start_month"],
season_end_month=data["season_end_month"],
is_active=data.get("is_active", True),
)
def to_json(self) -> str:
"""Serialize to JSON string."""
return json.dumps(self.to_dict(), indent=2)
@classmethod
def from_json(cls, json_str: str) -> "Sport":
"""Deserialize from JSON string."""
return cls.from_dict(json.loads(json_str))
@dataclass
class LeagueStructure:
"""Represents a league structure element (conference, division, etc.).
Attributes:
id: Unique ID (e.g., 'nba_eastern', 'mlb_al_east')
sport: Sport code (e.g., 'NBA', 'MLB')
structure_type: Type of structure (conference, division, league)
name: Full name (e.g., 'Eastern Conference', 'AL East')
abbreviation: Optional abbreviation (e.g., 'East', 'ALE')
parent_id: Parent structure ID (e.g., division's parent is conference)
display_order: Order for display (0-indexed)
"""
id: str
sport: str
structure_type: LeagueStructureType
name: str
abbreviation: Optional[str] = None
parent_id: Optional[str] = None
display_order: int = 0
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"id": self.id,
"sport": self.sport,
"structure_type": self.structure_type.value,
"name": self.name,
"abbreviation": self.abbreviation,
"parent_id": self.parent_id,
"display_order": self.display_order,
}
@classmethod
def from_dict(cls, data: dict) -> "LeagueStructure":
"""Create a LeagueStructure from a dictionary."""
return cls(
id=data["id"],
sport=data["sport"],
structure_type=LeagueStructureType(data["structure_type"]),
name=data["name"],
abbreviation=data.get("abbreviation"),
parent_id=data.get("parent_id"),
display_order=data.get("display_order", 0),
)
def to_json(self) -> str:
"""Serialize to JSON string."""
return json.dumps(self.to_dict(), indent=2)
@classmethod
def from_json(cls, json_str: str) -> "LeagueStructure":
"""Deserialize from JSON string."""
return cls.from_dict(json.loads(json_str))
def save_sports(sports: list[Sport], filepath: str) -> None:
"""Save a list of sports to a JSON file."""
with open(filepath, "w", encoding="utf-8") as f:
json.dump([s.to_dict() for s in sports], f, indent=2)
def load_sports(filepath: str) -> list[Sport]:
"""Load a list of sports from a JSON file."""
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return [Sport.from_dict(d) for d in data]
def save_league_structures(structures: list[LeagueStructure], filepath: str) -> None:
"""Save a list of league structures to a JSON file."""
with open(filepath, "w", encoding="utf-8") as f:
json.dump([s.to_dict() for s in structures], f, indent=2)
def load_league_structures(filepath: str) -> list[LeagueStructure]:
"""Load a list of league structures from a JSON file."""
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return [LeagueStructure.from_dict(d) for d in data]

View File

@@ -33,12 +33,20 @@ from ..utils.logging import get_logger
class RecordType(str, Enum):
"""CloudKit record types for SportsTime."""
"""CloudKit record types for SportsTime.
Must match CKRecordType constants in CKModels.swift.
"""
GAME = "Game"
TEAM = "Team"
STADIUM = "Stadium"
TEAM_ALIAS = "TeamAlias"
STADIUM_ALIAS = "StadiumAlias"
SPORT = "Sport"
LEAGUE_STRUCTURE = "LeagueStructure"
TRIP_POLL = "TripPoll"
POLL_VOTE = "PollVote"
ITINERARY_ITEM = "ItineraryItem"
@dataclass

View File

@@ -2,19 +2,44 @@
This module compares local records with CloudKit records to determine
what needs to be created, updated, or deleted.
Field names must match CKModels.swift exactly:
- Stadium: stadiumId, canonicalId, name, city, state, location (CLLocation),
capacity, yearOpened, imageURL, sport
- Team: teamId, canonicalId, name, abbreviation, sport, city, stadiumCanonicalId,
logoURL, primaryColor, secondaryColor
- Game: gameId, canonicalId, homeTeamCanonicalId, awayTeamCanonicalId,
stadiumCanonicalId, dateTime, sport, season, isPlayoff, broadcastInfo
- TeamAlias: aliasId, teamCanonicalId, aliasType, aliasValue, validFrom, validUntil
- StadiumAlias: aliasName, stadiumCanonicalId, validFrom, validUntil
- Sport: sportId, abbreviation, displayName, iconName, colorHex,
seasonStartMonth, seasonEndMonth, isActive
- LeagueStructure: structureId, sport, type, name, abbreviation, parentId, displayOrder
"""
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, date
from enum import Enum
from typing import Any, Optional
from ..models.game import Game
from ..models.team import Team
from ..models.stadium import Stadium
from ..models.aliases import TeamAlias, StadiumAlias, AliasType
from ..models.sport import Sport, LeagueStructure
from .cloudkit import CloudKitRecord, RecordType
def _date_to_datetime(d: Optional[date]) -> Optional[datetime]:
"""Convert a date to a datetime at midnight UTC.
CloudKit TIMESTAMP fields require datetime, not date.
"""
if d is None:
return None
return datetime(d.year, d.month, d.day, 0, 0, 0)
class DiffAction(str, Enum):
"""Action to take for a record."""
CREATE = "create"
@@ -98,24 +123,45 @@ class DiffResult:
class RecordDiffer:
"""Compares local records with CloudKit records."""
"""Compares local records with CloudKit records.
# Fields to compare for each record type
Field names must match CKModels.swift field keys exactly (camelCase).
"""
# Fields to compare for each record type (matching CKModels.swift keys)
GAME_FIELDS = [
"sport", "season", "home_team_id", "away_team_id", "stadium_id",
"game_date", "game_number", "home_score", "away_score", "status",
"gameId", "canonicalId", "sport", "season", "dateTime",
"homeTeamCanonicalId", "awayTeamCanonicalId", "stadiumCanonicalId",
"isPlayoff", "broadcastInfo",
]
TEAM_FIELDS = [
"sport", "city", "name", "full_name", "abbreviation",
"conference", "division", "primary_color", "secondary_color",
"logo_url", "stadium_id",
"teamId", "canonicalId", "sport", "city", "name", "abbreviation",
"stadiumCanonicalId", "logoURL", "primaryColor", "secondaryColor",
]
STADIUM_FIELDS = [
"sport", "name", "city", "state", "country",
"latitude", "longitude", "capacity", "surface",
"roof_type", "opened_year", "image_url", "timezone",
"stadiumId", "canonicalId", "sport", "name", "city", "state",
"location", "capacity", "yearOpened", "imageURL",
]
TEAM_ALIAS_FIELDS = [
"aliasId", "teamCanonicalId", "aliasType", "aliasValue",
"validFrom", "validUntil",
]
STADIUM_ALIAS_FIELDS = [
"aliasName", "stadiumCanonicalId", "validFrom", "validUntil",
]
SPORT_FIELDS = [
"sportId", "abbreviation", "displayName", "iconName",
"colorHex", "seasonStartMonth", "seasonEndMonth", "isActive",
]
LEAGUE_STRUCTURE_FIELDS = [
"structureId", "sport", "type", "name", "abbreviation",
"parentId", "displayOrder",
]
def diff_games(
@@ -184,6 +230,94 @@ class RecordDiffer:
self.STADIUM_FIELDS,
)
def diff_team_aliases(
self,
local_aliases: list[TeamAlias],
remote_records: list[dict],
) -> DiffResult:
"""Diff local team aliases against remote CloudKit records.
Args:
local_aliases: List of local TeamAlias objects
remote_records: List of remote record dictionaries
Returns:
DiffResult with creates, updates, deletes
"""
local_records = [self._team_alias_to_record(a) for a in local_aliases]
return self._diff_records(
local_records,
remote_records,
RecordType.TEAM_ALIAS,
self.TEAM_ALIAS_FIELDS,
)
def diff_stadium_aliases(
self,
local_aliases: list[StadiumAlias],
remote_records: list[dict],
) -> DiffResult:
"""Diff local stadium aliases against remote CloudKit records.
Args:
local_aliases: List of local StadiumAlias objects
remote_records: List of remote record dictionaries
Returns:
DiffResult with creates, updates, deletes
"""
local_records = [self._stadium_alias_to_record(a) for a in local_aliases]
return self._diff_records(
local_records,
remote_records,
RecordType.STADIUM_ALIAS,
self.STADIUM_ALIAS_FIELDS,
)
def diff_sports(
self,
local_sports: list[Sport],
remote_records: list[dict],
) -> DiffResult:
"""Diff local sports against remote CloudKit records.
Args:
local_sports: List of local Sport objects
remote_records: List of remote record dictionaries
Returns:
DiffResult with creates, updates, deletes
"""
local_records = [self._sport_to_record(s) for s in local_sports]
return self._diff_records(
local_records,
remote_records,
RecordType.SPORT,
self.SPORT_FIELDS,
)
def diff_league_structures(
self,
local_structures: list[LeagueStructure],
remote_records: list[dict],
) -> DiffResult:
"""Diff local league structures against remote CloudKit records.
Args:
local_structures: List of local LeagueStructure objects
remote_records: List of remote record dictionaries
Returns:
DiffResult with creates, updates, deletes
"""
local_records = [self._league_structure_to_record(s) for s in local_structures]
return self._diff_records(
local_records,
remote_records,
RecordType.LEAGUE_STRUCTURE,
self.LEAGUE_STRUCTURE_FIELDS,
)
def _diff_records(
self,
local_records: list[CloudKitRecord],
@@ -337,63 +471,206 @@ class RecordDiffer:
return value
def _game_to_record(self, game: Game) -> CloudKitRecord:
"""Convert a Game to a CloudKitRecord."""
"""Convert a Game to a CloudKitRecord.
Field names match CKGame keys in CKModels.swift:
- gameId, canonicalId: Unique identifiers
- homeTeamCanonicalId, awayTeamCanonicalId, stadiumCanonicalId: References as strings
- dateTime: Game time as datetime (will be converted to TIMESTAMP)
- sport: Sport code uppercase (e.g., "MLB")
- season: Season string (e.g., "2025-26" or "2026")
- isPlayoff: Boolean as int (1 or 0)
- broadcastInfo: Optional broadcast network string
"""
# Format season as string
sport_lower = game.sport.lower()
if sport_lower in ("nba", "nhl"):
season_str = f"{game.season}-{str(game.season + 1)[-2:]}"
else:
season_str = str(game.season)
return CloudKitRecord(
record_name=game.id,
record_type=RecordType.GAME,
fields={
"sport": game.sport,
"season": game.season,
"home_team_id": game.home_team_id,
"away_team_id": game.away_team_id,
"stadium_id": game.stadium_id,
"game_date": game.game_date,
"game_number": game.game_number,
"home_score": game.home_score,
"away_score": game.away_score,
"status": game.status,
"gameId": game.id,
"canonicalId": game.id,
"sport": game.sport.upper(),
"season": season_str,
"dateTime": game.game_date,
"homeTeamCanonicalId": game.home_team_id,
"awayTeamCanonicalId": game.away_team_id,
"stadiumCanonicalId": game.stadium_id,
"isPlayoff": False, # Default, can be overridden
"broadcastInfo": None, # Default, can be overridden
},
)
def _team_to_record(self, team: Team) -> CloudKitRecord:
"""Convert a Team to a CloudKitRecord."""
"""Convert a Team to a CloudKitRecord.
Field names match CKTeam keys in CKModels.swift:
- teamId, canonicalId: Unique identifiers
- name, abbreviation, city: Team info
- sport: Sport code uppercase (e.g., "NBA")
- stadiumCanonicalId: Home stadium canonical ID string
- logoURL: URL string for team logo
- primaryColor, secondaryColor: Hex color strings
"""
return CloudKitRecord(
record_name=team.id,
record_type=RecordType.TEAM,
fields={
"sport": team.sport,
"teamId": team.id,
"canonicalId": team.id,
"sport": team.sport.upper(),
"city": team.city,
"name": team.name,
"full_name": team.full_name,
"abbreviation": team.abbreviation,
"conference": team.conference,
"division": team.division,
"primary_color": team.primary_color,
"secondary_color": team.secondary_color,
"logo_url": team.logo_url,
"stadium_id": team.stadium_id,
"stadiumCanonicalId": team.stadium_id,
"logoURL": team.logo_url,
"primaryColor": team.primary_color,
"secondaryColor": team.secondary_color,
},
)
def _stadium_to_record(self, stadium: Stadium) -> CloudKitRecord:
"""Convert a Stadium to a CloudKitRecord."""
"""Convert a Stadium to a CloudKitRecord.
Field names match CKStadium keys in CKModels.swift:
- stadiumId, canonicalId: Unique identifiers
- name, city, state: Location info
- location: CloudKit LOCATION type with latitude/longitude
- capacity: Seating capacity as int
- yearOpened: Year opened as int
- imageURL: URL string for stadium image
- sport: Sport code uppercase (e.g., "MLB")
"""
return CloudKitRecord(
record_name=stadium.id,
record_type=RecordType.STADIUM,
fields={
"sport": stadium.sport,
"stadiumId": stadium.id,
"canonicalId": stadium.id,
"sport": stadium.sport.upper(),
"name": stadium.name,
"city": stadium.city,
"state": stadium.state,
"country": stadium.country,
"latitude": stadium.latitude,
"longitude": stadium.longitude,
# CloudKit LOCATION type expects dict with latitude/longitude
"location": {
"latitude": stadium.latitude,
"longitude": stadium.longitude,
},
"capacity": stadium.capacity,
"surface": stadium.surface,
"roof_type": stadium.roof_type,
"opened_year": stadium.opened_year,
"image_url": stadium.image_url,
"timezone": stadium.timezone,
"yearOpened": stadium.opened_year,
"imageURL": stadium.image_url,
},
)
def _team_alias_to_record(self, alias: TeamAlias) -> CloudKitRecord:
"""Convert a TeamAlias to a CloudKitRecord.
Field names match CKTeamAlias keys in CKModels.swift:
- aliasId: Unique identifier
- teamCanonicalId: The canonical team this alias resolves to
- aliasType: Type of alias ("abbreviation", "name", "city")
- aliasValue: The alias value to match
- validFrom, validUntil: Optional date bounds
- schemaVersion, lastModified: Versioning fields
"""
return CloudKitRecord(
record_name=alias.id,
record_type=RecordType.TEAM_ALIAS,
fields={
"aliasId": alias.id,
"teamCanonicalId": alias.team_canonical_id,
"aliasType": alias.alias_type.value,
"aliasValue": alias.alias_value,
"validFrom": _date_to_datetime(alias.valid_from),
"validUntil": _date_to_datetime(alias.valid_until),
"schemaVersion": 1,
"lastModified": datetime.utcnow(),
},
)
def _stadium_alias_to_record(self, alias: StadiumAlias) -> CloudKitRecord:
"""Convert a StadiumAlias to a CloudKitRecord.
Field names match CKStadiumAlias keys in CKModels.swift:
- aliasName: The alias name (used as record name/primary key)
- stadiumCanonicalId: The canonical stadium this alias resolves to
- validFrom, validUntil: Optional date bounds
- schemaVersion, lastModified: Versioning fields
"""
return CloudKitRecord(
record_name=alias.alias_name.lower(),
record_type=RecordType.STADIUM_ALIAS,
fields={
"aliasName": alias.alias_name.lower(),
"stadiumCanonicalId": alias.stadium_canonical_id,
"validFrom": _date_to_datetime(alias.valid_from),
"validUntil": _date_to_datetime(alias.valid_until),
"schemaVersion": 1,
"lastModified": datetime.utcnow(),
},
)
def _sport_to_record(self, sport: Sport) -> CloudKitRecord:
"""Convert a Sport to a CloudKitRecord.
Field names match CKSport keys in CKModels.swift:
- sportId: Unique identifier (e.g., 'MLB', 'NBA')
- abbreviation: Sport abbreviation
- displayName: Full display name
- iconName: SF Symbol name
- colorHex: Primary color as hex string
- seasonStartMonth, seasonEndMonth: Season boundary months (1-12)
- isActive: Whether sport is currently supported
- schemaVersion, lastModified: Versioning fields
"""
return CloudKitRecord(
record_name=sport.id,
record_type=RecordType.SPORT,
fields={
"sportId": sport.id,
"abbreviation": sport.abbreviation,
"displayName": sport.display_name,
"iconName": sport.icon_name,
"colorHex": sport.color_hex,
"seasonStartMonth": sport.season_start_month,
"seasonEndMonth": sport.season_end_month,
"isActive": sport.is_active,
"schemaVersion": 1,
"lastModified": datetime.utcnow(),
},
)
def _league_structure_to_record(self, structure: LeagueStructure) -> CloudKitRecord:
"""Convert a LeagueStructure to a CloudKitRecord.
Field names match CKLeagueStructure keys in CKModels.swift:
- structureId: Unique identifier (e.g., 'nba_eastern', 'mlb_al_east')
- sport: Sport code (e.g., 'NBA', 'MLB')
- type: Structure type ('conference', 'division', 'league')
- name: Full name
- abbreviation: Optional abbreviation
- parentId: Parent structure ID (e.g., division's parent is conference)
- displayOrder: Order for display (0-indexed)
- schemaVersion, lastModified: Versioning fields
"""
return CloudKitRecord(
record_name=structure.id,
record_type=RecordType.LEAGUE_STRUCTURE,
fields={
"structureId": structure.id,
"sport": structure.sport.upper(),
"type": structure.structure_type.value,
"name": structure.name,
"abbreviation": structure.abbreviation,
"parentId": structure.parent_id,
"displayOrder": structure.display_order,
"schemaVersion": 1,
"lastModified": datetime.utcnow(),
},
)
@@ -423,3 +700,39 @@ def stadium_to_cloudkit_record(stadium: Stadium) -> CloudKitRecord:
"""
differ = RecordDiffer()
return differ._stadium_to_record(stadium)
def team_alias_to_cloudkit_record(alias: TeamAlias) -> CloudKitRecord:
"""Convert a TeamAlias to a CloudKitRecord.
Convenience function for external use.
"""
differ = RecordDiffer()
return differ._team_alias_to_record(alias)
def stadium_alias_to_cloudkit_record(alias: StadiumAlias) -> CloudKitRecord:
"""Convert a StadiumAlias to a CloudKitRecord.
Convenience function for external use.
"""
differ = RecordDiffer()
return differ._stadium_alias_to_record(alias)
def sport_to_cloudkit_record(sport: Sport) -> CloudKitRecord:
"""Convert a Sport to a CloudKitRecord.
Convenience function for external use.
"""
differ = RecordDiffer()
return differ._sport_to_record(sport)
def league_structure_to_cloudkit_record(structure: LeagueStructure) -> CloudKitRecord:
"""Convert a LeagueStructure to a CloudKitRecord.
Convenience function for external use.
"""
differ = RecordDiffer()
return differ._league_structure_to_record(structure)