Source code for livef1.adapters.functions

# Standard Library Imports
from urllib.parse import urljoin
from pandas import to_datetime

# Internal Project Imports
from .livetimingf1_adapter import livetimingF1_request
from ..utils.exceptions import livef1Exception
from ..utils.helper import relocate_tz
from .jolpicaf1_adapter import jolpica_client


def merge_meetings(
    meetings_livetiming: dict,
    meetings_jolpica: dict
):
    """
    Merges meetings from Livetiming and Jolpica APIs.
    """
    live_meetings = {}
    for meeting in meetings_livetiming:
        name = meeting["Name"]
        if name in live_meetings: live_meetings[name + " 2"] = meeting
        else: live_meetings[name] = meeting
    meetings_livetiming = live_meetings
    meetings_jolpica = {race.race_name: race for race in meetings_jolpica}

    all_meetings = []
    # First, the meetings that are not in the Jolpica data
    live_only_meeting_keys = set(meetings_livetiming.keys()) - set(meetings_jolpica.keys())
    for key in live_only_meeting_keys:
        meeting = meetings_livetiming[key]
        meeting["round"] = 0
        all_meetings.append(meeting)
    
    # Second, the meetings that are in both data sources
    live_and_jol_meeting_keys = set(meetings_livetiming.keys()) & set(meetings_jolpica.keys())
    for key in live_and_jol_meeting_keys:
        meeting = meetings_livetiming[key]
        jol_meeting = meetings_jolpica[key]
        meeting["round"] = jol_meeting.round
        meeting["url"]  = jol_meeting.url
        meeting["Circuit"] = {None: None, **meeting["Circuit"], **jol_meeting.circuit.to_dict()}
        all_meetings.append(meeting)

    # Third, the meetings that are only in the Jolpica data
    jol_only_meeting_keys = set(meetings_jolpica.keys()) - set(meetings_livetiming.keys())
    for key in jol_only_meeting_keys:
        new_meeting = {}
        meeting = meetings_jolpica[key]

        new_meeting["Sessions"] = []
        if "FirstPractice" in meeting.to_dict().keys():
            new_meeting["Sessions"].append({
                "Key": "J" + meeting.round + "P1",
                "Type": "Practice",
                "Number": 1,
                "Name": "Practice 1",
                "StartDate": relocate_tz(
                    to_datetime(meeting.date + "T" + meeting.time),
                    source_tz = "UTC",
                    target_location = meeting.circuit.location.country).strftime("%Y-%m-%dT%H:%M:%S")
            })
        if "SecondPractice" in meeting.to_dict().keys():
            new_meeting["Sessions"].append({
                "Key": "J" + meeting.round + "P2",
                "Type": "Practice",
                "Number": 2,
                "Name": "Practice 2",
                "StartDate": relocate_tz(
                    to_datetime(meeting.date + "T" + meeting.time),
                    source_tz = "UTC",
                    target_location = meeting.circuit.location.country).strftime("%Y-%m-%dT%H:%M:%S")
            })
        if "ThirdPractice" in meeting.to_dict().keys():
            new_meeting["Sessions"].append({
                "Key": "J" + meeting.round + "P3",
                "Type": "Practice",
                "Number": 3,
                "Name": "Practice 3",
                "StartDate": relocate_tz(
                    to_datetime(meeting.third_practice.date + "T" + meeting.third_practice.time),
                    source_tz = "UTC",
                    target_location = meeting.circuit.location.country).strftime("%Y-%m-%dT%H:%M:%S")
            })
        if "Qualifying" in meeting.to_dict().keys():
            new_meeting["Sessions"].append({
                "Key": "J" + meeting.round + "Q",
                "Type": "Qualifying",
                "Name": "Qualifying",
                "StartDate": relocate_tz(
                    to_datetime(meeting.qualifying.date + "T" + meeting.qualifying.time),
                    source_tz = "UTC",
                    target_location = meeting.circuit.location.country).strftime("%Y-%m-%dT%H:%M:%S")
            })
        if "Sprint" in meeting.to_dict().keys():
            new_meeting["Sessions"].append({
                "Key": "J" + meeting.round + "S",
                "Type": "Race",
                "Number": -1,
                "Name": "Sprint",
                "StartDate": relocate_tz(
                    to_datetime(meeting.sprint.date + "T" + meeting.sprint.time),
                    source_tz = "UTC",
                    target_location = meeting.circuit.location.country).strftime("%Y-%m-%dT%H:%M:%S")
            })
        if "SprintQualifying" in meeting.to_dict().keys():
            new_meeting["Sessions"].append({
                "Key": "J" + meeting.round + "SQ",
                "Type": "Qualifying",
                "Number": -1,
                "Name": "Sprint Qualifying",
                "StartDate": relocate_tz(
                    to_datetime(meeting.sprint_qualifying.date + "T" + meeting.sprint_qualifying.time),
                    source_tz = "UTC",
                    target_location = meeting.circuit.location.country).strftime("%Y-%m-%dT%H:%M:%S")
            })
        new_meeting["Sessions"].append({
            "Key": "J" + meeting.round + "R",
            "Type": "Race",
            "Name": "Race",
            "StartDate": relocate_tz(
                to_datetime(meeting.date + "T" + (meeting.time or "00:00:00")),
                source_tz = "UTC",
                target_location = meeting.circuit.location.country).strftime("%Y-%m-%dT%H:%M:%S")
        })

        circuit_dict = meeting.circuit.to_dict()
        circuit_dict["ShortName"] = circuit_dict.get("circuitId", None)

        new_meeting["Key"] = "J" + meeting.round
        new_meeting["round"] = meeting.round
        new_meeting["url"]  = meeting.url
        new_meeting["Circuit"] = circuit_dict
        new_meeting["Location"] = meeting.circuit.location.locality
        new_meeting["Code"] = "F1" + str(meeting.season) + f"{int(meeting.round):02d}"
        new_meeting["OfficialName"] = None
        new_meeting["Country"] = {"name":meeting.circuit.location.country}
        new_meeting["Name"] = meeting.race_name
        new_meeting["Number"] = meeting.round
        all_meetings.append(new_meeting)
    return all_meetings


def _norm_schedule_str(value: object | None) -> str:
    if value is None:
        return ""
    return str(value).strip().casefold()


def fetch_jolpica_season_races_list(season_identifier: int) -> tuple[list, bool]:
    """Return ``(races, success)`` from Jolpica ``get_races`` only."""
    try:
        races = jolpica_client.query().season(season_identifier).get_races().data.races
        return (list(races) if races is not None else []), True
    except Exception:
        return [], False


def jolpica_season_races_fetch_ok(season_identifier: int) -> bool:
    """
    True if Jolpica ``get_races`` for the calendar year completes without error.

    Does not require a non-empty race list (API reachability / valid season query).
    """
    _, ok = fetch_jolpica_season_races_list(season_identifier)
    return ok


def livetiming_meeting_in_season_index(payload: dict, meeting_name: str) -> bool:
    """True if a Livetiming season ``Index.json`` payload lists the meeting by ``Name``."""
    if payload is None or not isinstance(payload, dict):
        return False
    target = _norm_schedule_str(meeting_name)
    if not target:
        return False
    meetings = payload.get("Meetings")
    if not meetings:
        return False
    if isinstance(meetings, dict):
        iterable = meetings.values()
    else:
        iterable = meetings
    for m in iterable:
        if _norm_schedule_str(m.get("Name")) == target:
            return True
    return False


def livetiming_session_in_season_index(
    payload: dict,
    meeting_name: str,
    session_name: str | None,
    session_type: str | None,
    session_number: int | None,
) -> bool:
    """
    True if the season index lists the session under the given meeting ``Name``.

    Matches ``Sessions`` entries by ``Name`` first, else by ``Type`` and ``Number``
    (aligned with :func:`merge_meetings` / season table session_type rules).
    """
    if not livetiming_meeting_in_season_index(payload, meeting_name):
        return False
    m_target = _norm_schedule_str(meeting_name)
    meeting_obj = None
    meetings = payload.get("Meetings")
    if isinstance(meetings, dict):
        iterable = meetings.values()
    else:
        iterable = meetings or []
    for m in iterable:
        if _norm_schedule_str(m.get("Name")) == m_target:
            meeting_obj = m
            break
    if meeting_obj is None:
        return False

    sn_target = _norm_schedule_str(session_name)
    st_target = _norm_schedule_str(session_type)

    for sess in meeting_obj.get("Sessions") or []:
        if sn_target and _norm_schedule_str(sess.get("Name")) == sn_target:
            return True
        st = _norm_schedule_str(sess.get("Type"))
        if not st_target or st != st_target:
            continue
        if "Number" in sess:
            if sess.get("Number") != session_number:
                continue
        return True
        # return session_number is None
    return False


def jolpica_meeting_in_races(races: list, meeting_name: str) -> bool:
    """True if any Jolpica race's ``race_name`` matches ``meeting_name`` (case-insensitive)."""
    target = _norm_schedule_str(meeting_name)
    if not target or not races:
        return False
    for race in races:
        rn = _norm_schedule_str(getattr(race, "race_name", None))
        if rn == target:
            return True
    return False


def _jolpica_race_dict(race: object) -> dict:
    if hasattr(race, "to_dict"):
        return race.to_dict()
    return dict(race) if isinstance(race, dict) else {}


def jolpica_find_race_for_meeting(races: list, meeting_name: str) -> object | None:
    target = _norm_schedule_str(meeting_name)
    if not target:
        return None
    for race in races or []:
        if _norm_schedule_str(getattr(race, "race_name", None)) == target:
            return race
    return None


def jolpica_session_available_on_race(
    race: object,
    session_name: str | None,
    session_type: str | None,
    session_number: int | None,
) -> bool:
    """
    Whether Jolpica exposes timing/date fields for this session on the given race row.

    Mirrors key presence used in :func:`merge_meetings` (``FirstPractice``, ``Sprint``, etc.).
    The main grand prix ``Race`` is always considered present when the race row exists.
    """
    d = _jolpica_race_dict(race)
    sn = _norm_schedule_str(session_name)
    st = _norm_schedule_str(session_type)
    num = session_number

    if sn == "race" or (st == "race" and num != -1):
        return True
    if sn == "sprint" or (st == "race" and num == -1):
        return "Sprint" in d
    if "sprint" in sn and "qualifying" in sn:
        return "SprintQualifying" in d
    if st == "qualifying" and num == -1:
        return "SprintQualifying" in d
    if sn == "qualifying" or st == "qualifying":
        return "Qualifying" in d

    practice_key = None
    if sn in ("practice 1", "fp1"):
        practice_key = "FirstPractice"
    elif sn in ("practice 2", "fp2"):
        practice_key = "SecondPractice"
    elif sn in ("practice 3", "fp3"):
        practice_key = "ThirdPractice"
    elif st == "practice" and num == 1:
        practice_key = "FirstPractice"
    elif st == "practice" and num == 2:
        practice_key = "SecondPractice"
    elif st == "practice" and num == 3:
        practice_key = "ThirdPractice"
    if practice_key:
        return practice_key in d

    return False


def fetch_livetiming_season_index(season_identifier: int) -> tuple[dict, bool]:
    """
    Fetch Livetiming season ``Index.json`` for a calendar year.

    Returns
    -------
    tuple[dict, bool]
        ``(payload, success)``. On failure, ``payload`` is ``{}`` and ``success`` is False.
    """
    try:
        data = livetimingF1_request(urljoin(str(season_identifier) + "/", "Index.json"))
        return data, True
    except Exception:
        return {}, False


def fetch_jolpica_season_meetings(season_identifier: int) -> tuple[list, bool, object | None]:
    """
    Fetch Jolpica season list entry and races for a calendar year.

    Returns
    -------
    tuple[list, bool, object | None]
        ``(races, success, season_row)`` where ``season_row`` is the matching season
        metadata (for wiki URL) or ``None``.
    """
    races, races_ok = fetch_jolpica_season_races_list(season_identifier)
    if not races_ok:
        return [], False, None
    try:
        seasons_data_jolpica = next(
            (
                season
                for season in jolpica_client.get_seasons(limit=100).data.seasons
                if int(season.season) == season_identifier
            ),
            None,
        )
        is_jolpica_available = bool(seasons_data_jolpica)
        return races, is_jolpica_available, seasons_data_jolpica
    except Exception:
        return races, False, None


def fetch_livetiming_session_index(full_path: str) -> tuple[dict, bool]:
    """
    Fetch Livetiming session ``Index.json`` (feeds manifest) for a built session URL path.

    Returns
    -------
    tuple[dict, bool]
        ``(payload, success)``. On failure, ``payload`` is ``{}`` and ``success`` is False.
    """
    try:
        data = livetimingF1_request(urljoin(full_path, "Index.json"))
        return data, True
    except Exception:
        return {}, False


def download_season_data(season_identifier: int):
    """
    Downloads and filters F1 data based on the provided season identifier.
    Parameters
    ----------
    season_identifier : :class:`int`
        The unique identifier for the F1 season.

    Returns
    ----------
    dict
        The filtered dataset containing the requested season data.
    """

    season_data_livetiming, is_livetiming_available = fetch_livetiming_season_index(season_identifier)
    season_races, is_jolpica_available, seasons_data_jolpica = fetch_jolpica_season_meetings(
        season_identifier
    )

    if not is_jolpica_available and not is_livetiming_available:
        raise livef1Exception("No data available for the season.")

    if is_livetiming_available:
        meetings_livetiming = season_data_livetiming["Meetings"]
    else: meetings_livetiming = {}
    if is_jolpica_available: meetings_jolpica = season_races
    else: meetings_jolpica = {}

    all_meetings = merge_meetings(meetings_livetiming, meetings_jolpica)

    season_data = {
        "is_livetiming_available": is_livetiming_available,
        "is_jolpica_available": is_jolpica_available,
        "wiki": seasons_data_jolpica.url if seasons_data_jolpica else None,
        "season": seasons_data_jolpica.season if seasons_data_jolpica else None,
        **season_data_livetiming,
        "Meetings": all_meetings,
        "livetiming_data": season_data_livetiming,
        "jolpica_data": season_races
    }

    return season_data


[docs] def download_data( season_identifier: int = None, location_identifier: str = None, session_identifier: str | int = None ): """ Downloads and filters F1 data based on the provided season, location, and session identifiers. Parameters ---------- season_identifier : :class:`int` The unique identifier for the F1 season. This is a required parameter. location_identifier : :class:`str` The location (circuit or country name) for filtering meetings (races). session_identifier : :class:`str` The session name (e.g., 'FP1', 'Qualifying') or key (integer) to filter a specific session within a meeting. Returns ---------- dict The filtered dataset containing the requested season, meeting, or session data. Raises ---------- livef1Exception Raised if any of the required parameters are missing or if no matching data is found. Examples ------------- .. code-block:: python print("Hello World") """ # Initialize a variable to store the final filtered data last_data = None # Ensure a season identifier is provided (mandatory) if season_identifier is None: raise livef1Exception("Please provide at least a `season_identifier`.") try: season_data = download_season_data(season_identifier) last_data = season_data # Default to entire season data initially # If a location (race circuit) is provided, filter the season data to find the specific meeting (race) if location_identifier: meeting_data = next( (meeting for meeting in season_data["Meetings"] if meeting["Location"] == location_identifier), None ) if meeting_data: last_data = meeting_data # Update with filtered meeting data else: raise livef1Exception(f"Meeting at location '{location_identifier}' not found.") else: meeting_data = season_data["Meetings"] # If a session (e.g., FP1, Qualifying) is provided, further filter the meeting data if session_identifier: if isinstance(session_identifier, str): # Filter by session name (string match) session_data = next( (session for session in meeting_data['Sessions'] if session['Name'] == session_identifier), None ) elif isinstance(session_identifier, int): # Filter by session key (integer match) session_data = next( (session for session in meeting_data['Sessions'] if session['Key'] == session_identifier), None ) if session_data: last_data = session_data # Update with filtered session data else: raise livef1Exception(f"Session with identifier '{session_identifier}' not found.") except Exception as e: # Catch any exception and wrap it in a custom livef1Exception raise livef1Exception(e) from e # Return the final filtered data (season, meeting, or session) return last_data