Source code for ilc_models

"""Data models for the ILC project"""

import abc
import datetime
import functools
import math
import re
from operator import attrgetter, itemgetter
from typing import Annotated, Any, Literal, NamedTuple, Optional, Self, cast

from pydantic import (
    BaseModel,
    Field,
    NonNegativeInt,
    PositiveInt,
    ValidatorFunctionWrapHandler,
    WrapValidator,
    model_validator,
)

__version__ = "0.2.1"


[docs] class RowTuple(NamedTuple): """Type for a single row of a league table. Elements are: (team, played, won, drawn, lost, goals_for, goals_against, gd, points, form) """ team: str played: int won: int drawn: int lost: int goals_for: int goals_against: int gd: int points: int form: str
[docs] class BasePlayer(BaseModel): """Basic level of player details. :param player_id: ID of this player in the API :type player_id: int :param name: Player's full (display) name :type name: str """ player_id: int name: str def __str__(self) -> str: return self.name def __eq__(self, other) -> bool: """Equality comparison. If `player_id` is non-zero the equality comparison will return `True` if player IDs match i.e. ignores different values of `name`. This is because there are often slight variances in the API between player names in events and in downloaded player data, which would otherwise cause mismatches when finding players in events. If `player_id` is zero in both `self` and `other` then the player names will also be compared. """ try: if self.player_id == 0 and other.player_id == 0: return self.name == other.name return self.player_id == other.player_id except AttributeError: # pragma: no cover return NotImplemented
[docs] def validate_dob(value: Any, handler: ValidatorFunctionWrapHandler) -> str: """Validate that a value conforms to a valid DOB string. Allows empty string, yyyy-m-d and yyyy-mm-dd. Any other format will raise a :exc:`ValueError`. :param value: DOB to validate :type value: :class:`typing.Any` :param handler: Pydantic validation handler :type handler: :class:`pydantic.ValidatorFunctionWrapHandler` :returns: Validated value in `yyyy-mm-dd` format :rtype: str :raises: :exc:`ValueError` if format is invalid """ # Will raise a ValidationError for a non-string dob = cast(str, handler(value)) # Accept the empty string if dob == "": return dob # yyyy-mm-dd regex pattern = re.compile(r"[12][09]\d{2}-[01]\d-[0-3]\d$") # Matches - return as validated if re.match(pattern, dob): return dob # Allow for missing zeros pattern = re.compile(r"[12][09]\d{2}-\d{1,2}-\d{1,2}$") if not re.match(pattern, dob): raise ValueError(f"{dob} is not a valid ISO date format.") # Convert to yyyy-mm-dd and return y, m, d = (int(n) for n in dob.split("-")) return f"{y}-{m:02}-{d:02}"
[docs] class Player(BasePlayer): """Full player details. :param first_name: Player's first name :type first_name: str :param last_name: Player's last name :type last_name: str :param dob: Player's date of birth in ISO (yyyy-mm-dd) format :type dob: str :param nationality: Player's nationality :type nationality: str """ first_name: str last_name: str dob: Annotated[str, WrapValidator(validate_dob)] nationality: str @property def base_player(self) -> BasePlayer: """Return a `BasePlayer` object corresponding to this `Player`. :returns: The `BasePlayer` corresponding to this `Player` :rtype: :class:`BasePlayer` """ return BasePlayer(player_id=self.player_id, name=self.name)
[docs] class Lineup(BaseModel): """Lineup for one team. Each lineup entry is an (int, BasePlayer) tuple, with the int being the player's shirt number if supplied (0 if not). :param starting: Starting XI (default=[]) :type starting: list[tuple[int, BasePlayer]] :param subs: Substitutes (default=[]) :type subs: list[tuple[int, BasePlayer]] """ starting: list[tuple[int, BasePlayer]] = Field(max_length=11, default=[]) subs: list[tuple[int, BasePlayer]] = [] def __bool__(self) -> bool: """Returns True if this lineup is populated with players.""" return len(self.starting) + len(self.subs) > 0
[docs] def sort(self): """Sorts the lineup. Each part of the lineup is sorted by shirt number, except for the first item in the starting lineup which is assumed to be the goalkeeper and is left as the first item. """ self.starting = self.starting[:1] + sorted(self.starting[1:], key=itemgetter(0)) self.subs.sort(key=itemgetter(0))
[docs] def players(self) -> list[BasePlayer]: """Returns all players in this lineup""" return [p[1] for p in self.starting] + [p[1] for p in self.subs]
def __len__(self) -> int: """Returns the total number of players in this lineup""" return len(self.starting) + len(self.subs)
[docs] class Lineups(BaseModel): """Match lineups for home and away teams. :param home: Home lineup (default=Lineup()) :type home: :class:`Lineup` :param away: Away lineup (default=Lineup()) :type away: :class:`Lineup` """ home: Lineup = Lineup() away: Lineup = Lineup() def __bool__(self) -> bool: """Returns True if either lineup is populated with players.""" return bool(self.home) or bool(self.away)
[docs] def sort(self): """Sort the lineups. Each part of the lineup is sorted by shirt number, except for the first item in the starting lineup which is assumed to be the goalkeeper and is left as the first item. """ self.home.sort() self.away.sort()
[docs] def players(self) -> list[BasePlayer]: """Returns all players in this lineup""" return self.home.players() + self.away.players()
def __len__(self) -> int: """Returns the total number of players in these lineups""" return len(self.home) + len(self.away)
[docs] @functools.total_ordering class EventTime(BaseModel): """The time an event occurred during a match. :param minutes: Minutes elapsed (1-120) :type minutes: int :param plus: Additional time minutes i.e. after 45, 90 etc. (default=0) :type plus: int """ minutes: int = Field(gt=0, le=120) plus: NonNegativeInt = 0
[docs] @model_validator(mode="after") def check_valid_time(self) -> Self: """Checks the `minutes` field is valid if `plus` is non-zero""" if self.plus != 0 and self.minutes not in (45, 90, 105, 120): raise ValueError("Additional time is only valid at the end of a half") return self
def __eq__(self, other: Any) -> bool: """Returns True if `self` and `other` are equal. :param other: Time to compare to :type other: Any :returns: `True` if the times are equal :rtype: bool """ try: return (self.minutes, self.plus) == (other.minutes, other.plus) except AttributeError: # pragma: no cover return NotImplemented def __gt__(self, other: Any) -> bool: """Returns True if `self` is greater than `other. :param other: Time to compare to :type other: Any :returns: `True` if `self` is greater than `other` :rtype: bool """ try: return (self.minutes, self.plus) > (other.minutes, other.plus) except AttributeError: # pragma: no cover return NotImplemented def __str__(self) -> str: """The event time in str format""" p = f"+{self.plus}" if self.plus else "" return f"{self.minutes}{p}'"
[docs] class BaseEvent(BaseModel, abc.ABC): """Abstract base class for events. :param team: Team this event relates to :type team: str :param time: Event time :type time: :class:`EventTime` """ team: str time: EventTime
[docs] def time_str(self) -> str: """The event time in str format""" return str(self.time)
[docs] @abc.abstractmethod def players(self) -> list[BasePlayer]: # pragma: no cover """Get the players involved in this event""" pass
[docs] class Goal(BaseEvent): """Represents a goal. :param event_type: The literal string 'goal' :type event_type: str :param goal_type: One of 'N' (normal goal), 'O' (own goal), 'P' (penalty) (default='N') :type goal_type: str :param scorer: Goal scorer :type scorer: :class:`BasePlayer` """ event_type: Literal["goal"] = Field(default="goal", frozen=True) goal_type: Literal["N", "O", "P"] = "N" scorer: BasePlayer
[docs] def players(self) -> list[BasePlayer]: """Get the players involved in this event""" return [self.scorer]
[docs] class Card(BaseEvent): """Represents a red or yellow card. :param event_type: The literal string 'card' :type event_type: str :param color: One of 'R' (red card), 'Y' (yellow card) :type color: str :param player: Player receiving the card :type player: :class:`BasePlayer` """ event_type: Literal["card"] = Field(default="card", frozen=True) color: Literal["Y", "R"] player: BasePlayer
[docs] def players(self) -> list[BasePlayer]: """Get the players involved in this event""" return [self.player]
[docs] class Substitution(BaseEvent): """Represents a substitution. :param event_type: The literal string 'sub' :type event_type: str :param player_on: Player entering the field :type player_on: :class:`BasePlayer` :param player_off: Player leaving the field :type player_off: :class:`BasePlayer` """ event_type: Literal["sub"] = Field(default="sub", frozen=True) player_on: BasePlayer player_off: BasePlayer
[docs] def players(self) -> list[BasePlayer]: """Get the players involved in this event""" return [self.player_off, self.player_on]
[docs] class LineupStatus(BaseEvent): """Whether a player is in the starting lineup or on the bench. :param event_type: The literal string 'status' :type event_type: str :param status: One of 'starting' or 'sub' :type status: str :param player: Player involved :type player: :class:`BasePlayer` """ event_type: Literal["status"] = Field(default="status", frozen=True) status: Literal["starting", "sub"] player: BasePlayer
[docs] def players(self) -> list[BasePlayer]: """Get the players involved in this event""" return [self.player]
type Event = Goal | Card | Substitution | LineupStatus
[docs] class Teams(BaseModel): """The teams in a match. :param home: Home team :type home: str :param away: Away team :type away: str """ home: str away: str
[docs] class Score(BaseModel): """Match score. :param home: Home team score (default=0) :type home: int :param away: Away team score (default=0) :type away: int """ home: NonNegativeInt = 0 away: NonNegativeInt = 0
[docs] class Match(BaseModel): """Represents a match. :param match_id: API ID of this match :type match_id: int :param kickoff: Date of match in ISO string format :type kickoff: str :param round: Round this match is part of :type round: str :param teams: Teams involved in this match :type teams: :class:`Teams` :param status: Match status :type status: str :param score: Score in this match (default=0-0) :type score: :class:`Score` :param goals: Detail of goals scored in the match :type goals: list[:class:`Goal`] :param cards: Detail of cards shown in the match :type cards: list[:class:`Card`] :param substitutions: Detail of substitutions made in the match :type substitutions: list[:class:`Substitution`] :param lineups: Match lineups :type lineups: :class:`Lineups` """ match_id: PositiveInt kickoff: str = Field( pattern=r"^[12][09]\d{2}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d[+-][01]\d:[0-5]\d$" ) round: str teams: Teams status: str score: Score = Score() goals: list[Goal] = [] cards: list[Card] = [] substitutions: list[Substitution] = [] lineups: Lineups = Lineups() @property def played(self) -> bool: return self.status in ("FT", "AET", "PEN") @property def date(self) -> datetime.date: return datetime.datetime.fromisoformat(self.kickoff).date()
[docs] def involves(self, team: str) -> bool: """Returns ``True`` if ``team`` is involved in this match. :param team: Team to query :type team: str :returns: ``True`` if ``team`` is involved in this match, i.e. either as the home team or the away team :rtype: bool """ return self.teams.home == team or self.teams.away == team
[docs] def events(self) -> list[Event]: """Returns all events in this match in chronological order. :returns: The combined list of goals, cards and subs in the match :rtype: list[:class:`Event`] """ e = self.goals + self.cards + self.substitutions return sorted(e, key=attrgetter("time"))
[docs] def players(self) -> list[BasePlayer]: """Returns all players involved in this match""" p = self.lineups.players() for event in self.events(): for player in event.players(): if player not in p: p.append(player) return p
[docs] def delete_event(self, event: Event) -> bool: """Delete an event from this match. :param event: Event to delete :type event: :class:`Event` :returns: `True` if the event was successfully deleted :raises: :exc:`ValueError` if the event is not found in the match """ return self.replace_event(event, None)
[docs] def replace_event(self, old: Event, new: Optional[Event]) -> bool: """Replace `old` with `new`. If `new` is `None` the event will be deleted. :param old: Event to replace :type old: :class:`Event` :param new: New event (`None` to delete the event) :type new: :class:`Event` | None :returns: `True` if the event was successfully replaced or deleted :raises: :exc:`ValueError` if the event is not found in the match :raises: :exc:`TypeError` if `old` and `new` are not the same event type """ n = -1 match old: case Goal(): for i, goal in enumerate(self.goals): if goal == old: n = i break if n != -1: if new is None: del self.goals[n] elif isinstance(new, Goal): self.goals[n] = new else: raise TypeError("new should be the same event type as old") case Card(): for i, card in enumerate(self.cards): if card == old: n = i break if n != -1: if new is None: del self.cards[n] elif isinstance(new, Card): self.cards[n] = new else: raise TypeError("new should be the same event type as old") case Substitution(): for i, sub in enumerate(self.substitutions): if sub == old: n = i break if n != -1: if new is None: del self.substitutions[n] elif isinstance(new, Substitution): self.substitutions[n] = new else: raise TypeError("new should be the same event type as old") if n == -1: raise ValueError("Event not found in Match") return True
def __str__(self) -> str: if self.played: return f"{self.teams.home} {self.score.home} - {self.score.away} {self.teams.away}" return f"{self.teams.home} vs {self.teams.away}"
[docs] @functools.total_ordering class TableRow(BaseModel): """A row in a league table. :param team: Team name :type team: str :param won: Matches won (default=0) :type won: int :param drawn: Matches drawn (default=0) :type drawn: int :param lost: Matches lost (default=0) :type lost: int :param scored: Goals scored (default=0) :type scored: int :param conceded: Goals conceded (default=0) :type conceded: int :param deducted: Points deducted (default=0) :type deducted: int :param form: Team form e.g. 'WDWWL' (default='') :type form: str """ team: str won: NonNegativeInt = 0 drawn: NonNegativeInt = 0 lost: NonNegativeInt = 0 scored: NonNegativeInt = 0 conceded: NonNegativeInt = 0 deducted: NonNegativeInt = 0 form: str = Field(pattern=r"^[WLD]{0,5}$", default="") @property def played(self) -> int: """Matches played. :returns: The total number of matches played :rtype: int """ return self.won + self.drawn + self.lost @property def gd(self) -> int: """Goal difference. :returns: The goal difference, i.e. goals scored minus goals conceded :rtype: int """ return self.scored - self.conceded @property def points(self) -> int: """Total points gained. :returns: The total number of points gained :rtype: int """ return self.won * 3 + self.drawn - self.deducted
[docs] def add_form(self, result: Literal["W", "D", "L"]) -> None: """Add a match result to the form field. Appends 'W', 'D' or 'L' to the team form, removing the oldest form indicator if the form is already displaying five matches, e.g. adding 'D' to 'WWLL' will result in 'WWLLD', while adding 'D' to 'WWLLD' will result in 'WLLDD'. :param result: Result to add ('W', 'L' or 'D') :type result: str """ if len(self.form) == 5: self.form = f"{self.form[1:]}{result}" else: self.form = f"{self.form}{result}"
[docs] def as_tuple(self) -> RowTuple: """Returns this row as a tuple. Elements are: (team, played, won, drawn, lost, scored, conceded, gd, points, form) :returns: This row as a tuple :rtype: :type:`RowTuple` """ return RowTuple( self.team, self.played, self.won, self.drawn, self.lost, self.scored, self.conceded, self.gd, self.points, self.form, )
[docs] @classmethod def from_tuple(cls, row_tuple: RowTuple) -> "TableRow": """Creates a `TableRow` instance from a `RowTuple`. :param row_tuple: Source tuple :type row_tuple: :class:`RowTuple` :returns: Newly created `TableRow` :rtype: :class:`TableRow` """ return cls( team=row_tuple.team, won=row_tuple.won, drawn=row_tuple.drawn, lost=row_tuple.lost, scored=row_tuple.goals_for, conceded=row_tuple.goals_against, form=row_tuple.form, )
def __eq__(self, other: Any) -> bool: """Returns True if `self` and `other` are equal. :param other: Row to compare to :type other: :class:`ilc_models.TableRow` :returns: `True` if the rows are equal :rtype: bool """ try: return (self.points, self.gd, self.scored, self.team) == ( other.points, other.gd, other.scored, other.team, ) except AttributeError: return NotImplemented def __gt__(self, other: Any) -> bool: # pragma: no cover """Returns True if `self` is greater than `other. Ordering is by points, GD and goals scored. If all are equal, team name will be compared in reverse alphabetical order so that a sorted league table will be ordered alphabetically. :param other: Row to compare to :type other: :class:`ilc_models.TableRow` :returns: `True` if `self` is greater than `other` :rtype: bool """ try: if (self.points, self.gd, self.scored) == ( other.points, other.gd, other.scored, ): return self.team < other.team return (self.points, self.gd, self.scored) > ( other.points, other.gd, other.scored, ) except AttributeError: return NotImplemented def __str__(self) -> str: return " ".join( ( self.team, f"P{self.played}", f"W{self.won}", f"D{self.drawn}", f"L{self.lost}", f"F{self.scored}", f"A{self.conceded}", f"GD{self.gd}", f"Pts{self.points}", self.form, ) )
[docs] def validate_deduction_date(value: Any, handler: ValidatorFunctionWrapHandler) -> str: """Validate that a value conforms to a valid DOB string. Allows empty string and yyyy-mm-dd. Any other format will raise a :exc:`ValueError`. :param value: DOB to validate :type value: :class:`typing.Any` :param handler: Pydantic validation handler :type handler: :class:`pydantic.ValidatorFunctionWrapHandler` :returns: Validated value in `yyyy-mm-dd` format :rtype: str :raises: :exc:`ValueError` if format is invalid """ # Will raise a ValidationError for a non-string date = cast(str, handler(value)) # Accept the empty string if date == "": return date # yyyy-mm-dd regex pattern = re.compile(r"[12][09]\d{2}-[01]\d-[0-3]\d$") # Matches - return as validated if re.match(pattern, date): return date raise ValueError(f"{date} is not a valid ISO date format.")
[docs] class Deduction(BaseModel): """A points deduction. :param team: Team to which this deduction applies :type team: str :param points: Number of points deducted :type points: int :param date: Date on which the deduction was implemented - if the empty string the deduction should be built in from the start of the season (default='') :type date: str """ team: str points: PositiveInt date: Annotated[str, WrapValidator(validate_deduction_date)] = ""
[docs] class EventInfo(BaseModel): """Event data with match info added. :param date: Date of match :type date: :class:`datetime.date` :param teams: Teams involved in the match :type teams: :class:`Teams` :param score: Match score :type score: :class:`Score` :param event: Event info :type event: :class:`Event` """ date: datetime.date teams: Teams score: Score event: Event
[docs] class League(BaseModel): """Represents a League. :param league_id: API ID of this league :type league_id: int :param name: Name of this league e.g. Premiership :type name: str :param year: Year this season starts :type year: int :param start: League start date as an ISO format string :type start: str :param end: League end date as an ISO format string :type end: str :param current: Whether this league is still being played :type current: bool :param coverage: Coverage available from the API :type coverage: dict[str, bool] :param teams: The name of each team in this league (default=[]) :type teams: list[str] :param rounds: This league's rounds, with matches for each round (default={}) :type rounds: dict[str, list[:class:`Match`]] :param excluded: Rounds to exclude from import (default=[]) :type excluded: list[str] :param split: Split point of this league (default=0) :type split: int :param players: Players who feature in this league :type players: dict[int, :class:'Player'] """ league_id: PositiveInt name: str year: PositiveInt start: str = Field(pattern=r"^[12][09]\d{2}-[01]\d-[0-3]\d$") end: str = Field(pattern=r"^[12][09]\d{2}-[01]\d-[0-3]\d$") current: bool coverage: dict[str, bool] teams: list[str] = [] rounds: dict[str, list[Match]] = {} excluded: list[str] = [] deductions: list[Deduction] = [] split: NonNegativeInt = 0 players: dict[str, Player] = {} @property def title(self) -> str: """Title of this league e.g. Premiership 2023/24 :returns: Title of the league :rtype: str """ year1 = int(self.start[:4]) year2 = int(self.end[:4]) season = year1 if year1 == year2 else f"{year1}/{year2 % 100}" return f"{self.name} {season}"
[docs] def matches(self, team: Optional[str] = None) -> list[Match]: """Matches in this league. If ``team`` is given, return only matches involving this team, otherwise return all matches. :param team: Get matches for this team (default=None) :type team: str :returns: Matches sorted by date and then home team :rtype: list[:class:`Match`] """ _matches = [] for m in self.rounds.values(): for match in m: if team is None or match.involves(team): _matches.append(match) _matches.sort(key=attrgetter("teams.home")) _matches.sort(key=attrgetter("date")) return _matches
[docs] def events(self, player: BasePlayer) -> list[EventInfo]: """Get all events in this league in which a player is involved. Also includes a :class:`LineupStatus` event for any matches which include the player in their lineup. :param player: Find events featuring this player :type player: :class:`BasePlayer` :returns: The events in this league involving the player :rtype: list[:class:`EventInfo`] """ e = [] for match in self.matches(): # Find player in lineups status = None for team, lineup in zip( (match.teams.home, match.teams.away), (match.lineups.home, match.lineups.away), ): if player in (p[1] for p in lineup.starting): status = LineupStatus( team=team, time=EventTime(minutes=1), status="starting", player=player, ) elif player in (p[1] for p in lineup.subs): status = LineupStatus( team=team, time=EventTime(minutes=1), status="sub", player=player, ) if status: e.append( EventInfo( date=match.date, teams=match.teams, score=match.score, event=status, ) ) break # Find player in events for event in match.events(): if player in event.players(): e.append( EventInfo( date=match.date, teams=match.teams, score=match.score, event=event, ) ) return e
[docs] def update_player( self, old: BasePlayer, new: BasePlayer, team: Optional[str] = None ): """Replace all occurrences of ``old`` with ``new``. Searches all lineups and events in this league and replaces all occurrences of ``old`` with ``new``. If `team` is given, replace only occurrences where the player features for `team`. :param old: Player to be replaced :type old: :class:`BasePlayer` :param new: New player details :type new: :class:`BasePlayer` :param team: If given, replace only where the player features for this team (default=None) :type team: str """ for matches in self.rounds.values(): for match in matches: if team is None or match.involves(team): if old in match.players(): lineups = [] if team is None or team == match.teams.home: lineups.append(match.lineups.home) if team is None or team == match.teams.away: lineups.append(match.lineups.away) # Update lineups for lineup in lineups: for plist in ( lineup.starting, lineup.subs, ): for n, player in enumerate(plist): if player[1] == old: plist[n] = (player[0], new) break else: continue break # Update events for goal in match.goals: if ( team is None or (goal.goal_type == "O" and goal.team != team) or (goal.goal_type != "O" and goal.team == team) ): if goal.scorer == old: goal.scorer = new for card in match.cards: if ( team is None or card.team == team ) and card.player == old: card.player = new for sub in match.substitutions: if team is None or sub.team == team: if sub.player_on == old: sub.player_on = new elif sub.player_off == old: sub.player_off = new
[docs] def player_teams(self, player: BasePlayer) -> list[str]: """Get all the teams a player features in during this season. :param player: Find teams this player plays for :type player: :class:`BasePlayer` :returns: The teams the player plays for in this league :rtype: list[str] """ teams = set() # Check each match as a player may play for more than # one team during a single season for match in self.matches(): if player in match.players(): found = False # Check lineups if match.lineups: if player in match.lineups.home.players(): teams.add(match.teams.home) found = True elif player in match.lineups.away.players(): teams.add(match.teams.away) found = True # If the player is not in the lineups # check all events if not found: for event in match.events(): match event: case Goal(scorer=scorer, goal_type=goal_type): if scorer == player: # Adjust for own goal (an event for the opposing team) if goal_type == "O": teams.add( match.teams.home if event.team == match.teams.away else match.teams.away ) else: teams.add(event.team) break case Card(player=card_player): if card_player == player: teams.add(event.team) break case Substitution( player_on=player_on, player_off=player_off ): if player in (player_on, player_off): teams.add(event.team) break return list(teams)
[docs] def table( self, played: int = 0, split_point: bool = False, date: Optional[datetime.date] = None, ) -> list[RowTuple]: """Get the league table. Returns the league table as a list of :type:`RowTuple` items, ordered by league position. If ``played`` is non-zero the table will be returned at the first point at which all teams have played at least ``played`` matches. If ``split_point`` is ``True`` the table will be returned at the league's split point. If ``date`` is not ``None`` the table will be returned at the given date, i.e. taking account only of matches where the date is earlier than or equal to ``date``. :param played: If non-zero, get table after this number of games (default=0) :type played: int :param split_point: If True get the table at the split point (default=False) :type split_point: bool :param date: If given, get the league table on this date (default=None) :type date: :class:`datetime.date` :returns: The league table as a list of tuples :rtype: list[:type:`RowTuple`] """ # Create rows rows = {team: TableRow(team=team) for team in self.teams} # Get table at split point if split_point: played = self.split # Retain the date of the last match processed to manage deductions last_date = None # Add matches for match in (m for m in self.matches() if m.played): # Check for date if date is not None and match.date > date: last_date = date break # Get rows home = rows[match.teams.home] away = rows[match.teams.away] # W/D/L if match.score.home > match.score.away: home.won += 1 away.lost += 1 home.add_form("W") away.add_form("L") elif match.score.home < match.score.away: home.lost += 1 away.won += 1 home.add_form("L") away.add_form("W") else: home.drawn += 1 away.drawn += 1 home.add_form("D") away.add_form("D") # Goals home.scored += match.score.home home.conceded += match.score.away away.scored += match.score.away away.conceded += match.score.home # Retain date last_date = match.date # Check if all teams have played the required number of games if played and all(row.played >= played for row in rows.values()): break # Handle deductions for deduction in self.deductions: if not deduction.date or ( last_date is not None and datetime.date.fromisoformat(deduction.date) <= last_date ): rows[deduction.team].deducted += deduction.points # Handle split if ( not split_point and self.split and any(row.played > self.split for row in rows.values()) ): # Post-split - find the positions at the split point split = self.table(split_point=True) order = [row[0] for row in split] # Get the top and bottom half teams at the split # (odd number puts the extra team into the top half) team_count = math.ceil(len(rows) / 2) top_teams = order[:team_count] bottom_teams = order[team_count:] else: # Pre-split - just put all teams into the top section top_teams = list(rows.keys()) bottom_teams = [] # Sort top and bottom separately top = sorted((rows[team] for team in top_teams), reverse=True) bottom = sorted((rows[team] for team in bottom_teams), reverse=True) return [row.as_tuple() for row in top] + [row.as_tuple() for row in bottom]
[docs] def head_to_head(self, teams: tuple[str, str]) -> list[Match]: """Get all matches played between two teams. :param teams: Teams to query :type teams: tuple[str, str] :returns: All matches between these two teams :rtype: list[:class:`Match`] """ return [match for match in self.matches(teams[0]) if match.involves(teams[1])]
def __str__(self) -> str: # pragma: no cover return self.title