"""Provides the class MvgApi.""" from __future__ import annotations import asyncio import re import threading from enum import Enum from http import HTTPStatus from typing import Any, Coroutine, TypeVar import aiohttp from furl import furl MVGAPI_DEFAULT_LIMIT = 10 # API defaults to 10, limits to 100 # Generic result type for _run T = TypeVar("T") class Base(Enum): """MVG APIs base URLs.""" FIB = "https://www.mvg.de/api/bgw-pt/v3" ZDM = "https://www.mvg.de/.rest/zdm" class Endpoint(Enum): """MVG API endpoints with URLs and arguments.""" FIB_LOCATION = ("/locations", ["query"]) FIB_NEARBY = ("/stations/nearby", ["latitude", "longitude"]) FIB_DEPARTURE = ("/departures", ["globalId", "limit", "offsetInMinutes"]) ZDM_STATION_IDS = ("/mvgStationGlobalIds", ...) ZDM_STATIONS = ("/stations", ...) ZDM_LINES = ("/lines", ...) class TransportType(Enum): """MVG products defined by the API with name and icon.""" BAHN = ("Bahn", "mdi:train") SBAHN = ("S-Bahn", "mdi:subway-variant") UBAHN = ("U-Bahn", "mdi:subway") TRAM = ("Tram", "mdi:tram") BUS = ("Bus", "mdi:bus") REGIONAL_BUS = ("Regionalbus", "mdi:bus") SEV = ("SEV", "mdi:taxi") SCHIFF = ("Schiff", "mdi:ferry") @classmethod def all(cls) -> list[TransportType]: """Return a list of all products.""" return [getattr(TransportType, c.name) for c in cls if c.name != "SEV"] class MvgApiError(Exception): """Failed communication with MVG API.""" class MvgApi: """A class interface to retrieve stations, lines and departures from the MVG. The implementation uses the Münchner Verkehrsgesellschaft (MVG) API at https://www.mvg.de. It can be instanciated by station name and place or global station id. :param station: global station id (e.g. 'de:09162:70') :raises MvgApiError: raised on communication failure or unexpected result :raises ValueError: raised on bad station id format """ def __init__(self, station: str) -> None: """Initialize the MVG interface.""" station = station.strip() if not self.valid_station_id(station): msg = "Invalid station." raise ValueError(msg) station_details = self.station(station) if station_details: self.station_id = station_details["id"] @staticmethod def valid_station_id(station_id: str, validate_existance: bool = False) -> bool: """Check if the station id is a global station ID according to VDV Recommendation 432. :param station_id: a global station id (e.g. 'de:09162:70') :param validate_existance: validate the existence in a list from the API :return: True if valid, False if Invalid """ valid_format = bool(re.match("de:[0-9]{2,5}:[0-9]+", station_id)) if not valid_format: return False if validate_existance: try: result = MvgApi._run(MvgApi.__api(Base.ZDM, Endpoint.ZDM_STATION_IDS)) if not isinstance(result, list): msg = f"Bad API call: Expected a list, but got {type(result)}." raise MvgApiError(msg) except (AssertionError, KeyError) as exc: msg = "Bad API call: Could not parse station data." raise MvgApiError(msg) from exc else: return station_id in result return True @staticmethod async def __api(base: Base, endpoint: Endpoint | tuple[str, list[str]], args: dict[str, Any] | None = None) -> Any: # noqa: ANN401 """Call the API endpoint with the given arguments. :param base: the API base :param endpoint: the endpoint :param args: a dictionary containing arguments :raises MvgApiError: raised on communication failure or unexpected result :return: the response as JSON object """ url = furl(base.value) url /= endpoint.value[0] if isinstance(endpoint, Endpoint) else endpoint[0] url.set(query_params=args) try: async with aiohttp.ClientSession(trust_env=True) as session, session.get( url.url, ) as resp: if resp.status != HTTPStatus.OK: msg = f"Bad API call: Got response ({resp.status}) from {url.url}." raise MvgApiError(msg) if resp.content_type != "application/json": msg = f"Bad API call: Got content type {resp.content_type} from {url.url}." raise MvgApiError(msg) return await resp.json() except aiohttp.ClientError as exc: msg = f"Bad API call: Got {type(exc)!s} from {url.url}" raise MvgApiError from exc @staticmethod async def station_ids_async() -> list[str]: """Retrieve a list of all valid station ids. :raises MvgApiError: raised on communication failure or unexpected result :return: station ids as a list """ try: result = await MvgApi.__api(Base.ZDM, Endpoint.ZDM_STATION_IDS) if not isinstance(result, list): msg = f"Bad API call: Expected a list, but got {type(result)}." raise MvgApiError(msg) return sorted(result) except (AssertionError, KeyError) as exc: msg = "Bad API call: Could not parse station data." raise MvgApiError(msg) from exc @staticmethod async def stations_async() -> list[dict[str, Any]]: """Retrieve a list of all stations. :raises MvgApiError: raised on communication failure or unexpected result :return: a list of stations as dictionary """ try: result = await MvgApi.__api(Base.ZDM, Endpoint.ZDM_STATIONS) if not isinstance(result, list): msg = f"Bad API call: Expected a list, but got {type(result)}." raise MvgApiError(msg) except (AssertionError, KeyError) as exc: msg = "Bad API call: Could not parse station data." raise MvgApiError(msg) from exc else: return result @staticmethod def stations() -> list[dict[str, Any]]: """Retrieve a list of all stations. :raises MvgApiError: raised on communication failure or unexpected result :return: a list of stations as dictionary """ return MvgApi._run(MvgApi.stations_async()) @staticmethod async def lines_async() -> list[dict[str, Any]]: """Retrieve a list of all lines. :raises MvgApiError: raised on communication failure or unexpected result :return: a list of lines as dictionary """ try: result = await MvgApi.__api(Base.ZDM, Endpoint.ZDM_LINES) if not isinstance(result, list): msg = f"Bad API call: Expected a list, but got {type(result)}." raise MvgApiError(msg) except (AssertionError, KeyError) as exc: msg = "Bad API call: Could not parse station data." raise MvgApiError(msg) from exc else: return result @staticmethod def lines() -> list[dict[str, Any]]: """Retrieve a list of all lines. :raises MvgApiError: raised on communication failure or unexpected result :return: a list of lines as dictionary """ return MvgApi._run(MvgApi.lines_async()) @staticmethod async def station_async(query: str) -> dict[str, str] | None: """Find a station by station name and place or global station id. :param query: name, place ('Universität, München') or global station id (e.g. 'de:09162:70') :raises MvgApiError: raised on communication failure or unexpected result :return: the first matching station as dictionary with keys 'id', 'name', 'place', 'latitude', 'longitude' Example result:: { "id": "de:09162:6", "name": "Hauptbahnhof", "place": "München", "latitude": 48.14003, "longitude": 11.56107, } """ query = query.strip() try: # return details from ZDM if query is a station id if MvgApi.valid_station_id(query): stations_endpoint = Endpoint.ZDM_STATIONS.value[0] station_endpoint = f"{stations_endpoint}/{query}" result = await MvgApi.__api(Base.ZDM, (station_endpoint, [])) if not isinstance(result, dict): msg = f"Bad API call: Expected a dict, but got {type(result)}." raise MvgApiError(msg) return { "id": result["id"], "name": result["name"], "place": result["place"], "latitude": result["latitude"], "longitude": result["longitude"], } # use open search if query is not a station id args = dict.fromkeys(Endpoint.FIB_LOCATION.value[1]) args.update({"query": query.strip(), "locationTypes": "STATION"}) result = await MvgApi.__api(Base.FIB, Endpoint.FIB_LOCATION, args) if not isinstance(result, list): msg = f"Bad API call: Expected a list, but got {type(result)}." raise MvgApiError(msg) # return first location if lis is not empty if len(result) > 0: return { "id": result[0]["globalId"], "name": result[0]["name"], "place": result[0]["place"], "latitude": result[0]["latitude"], "longitude": result[0]["longitude"], } except (AssertionError, KeyError) as exc: msg = "Bad API call: Could not parse station data." raise MvgApiError(msg) from exc else: # return None if no station was found return None @staticmethod def station(query: str) -> dict[str, str] | None: """Find a station by station name and place or global station id. :param query: name, place ('Universität, München') or global station id (e.g. 'de:09162:70') :raises MvgApiError: raised on communication failure or unexpected result :return: the fist matching station as dictionary with keys 'id', 'name', 'place', 'latitude', 'longitude' Example result:: { "id": "de:09162:6", "name": "Hauptbahnhof", "place": "München", "latitude": 48.14003, "longitude": 11.56107, } """ return MvgApi._run(MvgApi.station_async(query)) @staticmethod async def nearby_async( latitude: float, longitude: float, full_list: bool = True, ) -> dict[str, str] | list[dict[str, str]] | None: """Find the nearest station by coordinates. :param latitude: coordinate in decimal degrees :param longitude: coordinate in decimal degrees :param full_list: return full list of stations instead of a single location :raises MvgApiError: raised on communication failure or unexpected result :return: the fist matching station as dictionary with keys 'id', 'name', 'place', 'latitude', 'longitude' or a list of such station dictionaries, if requested by `full_list` argument Example result:: {"id": "de:09162:70", "name": "Universität", "place": "München", "latitude": 48.15007, "longitude": 11.581} """ try: args = dict.fromkeys(Endpoint.FIB_NEARBY.value[1]) args.update({"latitude": latitude, "longitude": longitude}) result = await MvgApi.__api(Base.FIB, Endpoint.FIB_NEARBY, args) if not isinstance(result, list): msg = f"Bad API call: Expected a list, but got {type(result)}." raise MvgApiError(msg) if len(result) > 0: locations = [ { "id": location["globalId"], "name": location["name"], "place": location["place"], "latitude": location["latitude"], "longitude": location["longitude"], } for location in result ] # return full list or only nearest location return locations if full_list else locations[0] except (AssertionError, KeyError) as exc: msg = "Bad API call: Could not parse station data." raise MvgApiError(msg) from exc else: # return None if no station was found return None @staticmethod def nearby( latitude: float, longitude: float, full_list: bool = False, ) -> dict[str, str] | list[dict[str, str]] | None: """Find the nearest station by coordinates. :param latitude: coordinate in decimal degrees :param longitude: coordinate in decimal degrees :param full_list: return full list of stations instead of a single location :raises MvgApiError: raised on communication failure or unexpected result :return: the fist matching station as dictionary with keys 'id', 'name', 'place', 'latitude', 'longitude' or a list of such station dictionaries, if requested by `full_list` argument Example result:: {"id": "de:09162:70", "name": "Universität", "place": "München", "latitude": 48.15007, "longitude": 11.581} """ return MvgApi._run(MvgApi.nearby_async(latitude, longitude, full_list)) @staticmethod async def departures_async( station_id: str, limit: int = MVGAPI_DEFAULT_LIMIT, offset: int = 0, transport_types: list[TransportType] | None = None, ) -> list[dict[str, Any]]: """Retrieve the next departures for a station by station id. :param station_id: the global station id ('de:09162:70') :param limit: limit of departures, defaults to 10 :param offset: offset (e.g. walking distance to the station) in minutes, defaults to 0 :param transport_types: filter by transport type, defaults to None :raises MvgApiError: raised on communication failure or unexpected result :raises ValueError: raised on bad station id format :return: a list of departures as dictionary Example result:: [ { "time": 1668524580, "planned": 1668524460, "line": "U3", "destination": "Fürstenried West", "type": "U-Bahn", "icon": "mdi:subway", "cancelled": False, "messages": [], }, ..., ] """ station_id.strip() if not MvgApi.valid_station_id(station_id): msg = "Invalid format of global station ID." raise ValueError(msg) try: args = dict.fromkeys(Endpoint.FIB_DEPARTURE.value[1]) args.update({"globalId": station_id, "offsetInMinutes": offset, "limit": limit}) if transport_types is None: transport_types = TransportType.all() args.update({"transportTypes": ",".join([product.name for product in transport_types])}) result = await MvgApi.__api(Base.FIB, Endpoint.FIB_DEPARTURE, args) if not isinstance(result, list): msg = f"Bad API call: Expected a list, but got {type(result)}." raise MvgApiError(msg) departures = [ { "time": int(departure["realtimeDepartureTime"] / 1000), "planned": int(departure["plannedDepartureTime"] / 1000), "line": departure["label"], "destination": departure["destination"], "type": TransportType[departure["transportType"]].value[0], "icon": TransportType[departure["transportType"]].value[1], "cancelled": departure["cancelled"], "messages": departure["messages"], } for departure in result ] except (AssertionError, KeyError) as exc: msg = "Bad MVG API call: Invalid departure data." raise MvgApiError(msg) from exc else: return departures def departures( self, limit: int = MVGAPI_DEFAULT_LIMIT, offset: int = 0, transport_types: list[TransportType] | None = None, ) -> list[dict[str, Any]]: """Retrieve the next departures. :param limit: limit of departures, defaults to 10 :param offset: offset (e.g. walking distance to the station) in minutes, defaults to 0 :param transport_types: filter by transport type, defaults to None :raises MvgApiError: raised on communication failure or unexpected result :return: a list of departures as dictionary Example result:: [ { "time": 1668524580, "planned": 1668524460, "line": "U3", "destination": "Fürstenried West", "type": "U-Bahn", "icon": "mdi:subway", "cancelled": False, "messages": [], }, ..., ] """ return MvgApi._run(self.departures_async(self.station_id, limit, offset, transport_types)) # Single background loop/thread used when a running loop exists in this # thread (e.g. Jupyter). Created lazily on first use. _bg_lock = threading.Lock() _bg_loop: asyncio.AbstractEventLoop | None = None _bg_thread: threading.Thread | None = None @staticmethod def _run(coro: Coroutine[Any, Any, T]) -> T: """Execute a coroutine from synchronous code. - If no event loop is running in the current thread, this uses `asyncio.run(coro)` (the normal, fast path). - If an event loop is already running (e.g. inside Jupyter), it submits the coroutine to a single background event loop that is created once and reused for subsequent calls. The background loop approach avoids calling `asyncio.run()` from a running loop which raises RuntimeError. """ try: return asyncio.run(coro) except RuntimeError: # Running inside an existing loop in this thread; fall back to # the background loop approach. if not asyncio.iscoroutine(coro): msg = "_run expects a coroutine object" raise TypeError(msg) from None with MvgApi._bg_lock: if MvgApi._bg_loop is None: loop = asyncio.new_event_loop() def _start_bg_loop() -> None: asyncio.set_event_loop(loop) loop.run_forever() thread = threading.Thread(target=_start_bg_loop, daemon=True) thread.start() MvgApi._bg_loop = loop MvgApi._bg_thread = thread if MvgApi._bg_loop is None: msg = "Failed to create background event loop" raise RuntimeError(msg) from None future = asyncio.run_coroutine_threadsafe(coro, MvgApi._bg_loop) return future.result()