diff --git a/README.md b/README.md index e69e0b5..63760fc 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,17 @@ pip install dvb ``` ```python -import dvb +from dvb import Client + +client = Client(user_agent="my-app/1.0 (me@example.com)") ``` +All API access goes through a `Client` instance, which requires a `user_agent` string identifying your project and providing contact details. + ## Find stops ```python -dvb.find("Helmholtzstraße") +client.find("Helmholtzstraße") ``` ```python @@ -29,7 +33,7 @@ dvb.find("Helmholtzstraße") ## Monitor a stop ```python -dvb.monitor("Helmholtzstraße", limit=2) +client.monitor("Helmholtzstraße", limit=2) ``` ```python @@ -54,7 +58,7 @@ Stop names are automatically resolved to IDs. You can also pass a numeric stop I ## Plan a route ```python -dvb.route("Helmholtzstraße", "Postplatz") +client.route("Helmholtzstraße", "Postplatz") ``` ```python @@ -80,14 +84,14 @@ dvb.route("Helmholtzstraße", "Postplatz") ] ``` -Use the `session_id` to paginate with `dvb.earlier_later()`. +Use the `session_id` to paginate with `client.earlier_later()`. ## Map pins Search for stops, POIs, and other points of interest within a bounding box. ```python -dvb.pins(51.04, 13.70, 51.05, 13.72, pin_types=("Stop", "Platform")) +client.pins(51.04, 13.70, 51.05, 13.72, pin_types=("Stop", "Platform")) ``` ```python @@ -101,7 +105,7 @@ dvb.pins(51.04, 13.70, 51.05, 13.72, pin_types=("Stop", "Platform")) ## Lines at a stop ```python -dvb.lines("33000742") +client.lines("33000742") ``` ```python @@ -115,7 +119,7 @@ dvb.lines("33000742") ## Route changes ```python -dvb.route_changes() +client.route_changes() ``` ```python @@ -137,14 +141,14 @@ dvb.route_changes() Get all stops for a specific departure (using the ID and time from a monitor response). ```python -departure = dvb.monitor("Helmholtzstraße")[0] -dvb.trip_details(trip_id=departure.id, time=departure.scheduled, stop_id="33000742") +departure = client.monitor("Helmholtzstraße")[0] +client.trip_details(trip_id=departure.id, time=departure.scheduled, stop_id="33000742") ``` ## Reverse geocoding ```python -dvb.address(51.04373, 13.70320) +client.address(51.04373, 13.70320) ``` ```python @@ -153,29 +157,39 @@ Stop(id='33000144', name='Tharandter Straße', city='Dresden', coords=Coords(... ## Raw responses -All functions accept `raw=True` to get the unprocessed API response as a dict: +All methods accept `raw=True` to get the unprocessed API response as a dict: ```python -dvb.monitor("Helmholtzstraße", raw=True) +client.monitor("Helmholtzstraße", raw=True) # Returns the raw JSON dict from the WebAPI ``` ## Error handling ```python -from dvb import APIError, ConnectionError +from dvb import Client, APIError, ConnectionError + +client = Client(user_agent="my-app/1.0 (me@example.com)") try: - dvb.monitor("Helmholtzstraße") + client.monitor("Helmholtzstraße") except ConnectionError: print("Network error or timeout") except APIError: print("API returned an error") ``` +## Migrating from 2.x + +dvb 3.0 introduces a `Client` class that requires a `user_agent` string. This helps the DVB/VVO identify API consumers and provides them with a way to reach out if needed. + +- All functions have moved from `dvb.function()` to `client.function()` on a `Client` instance +- `import dvb` + `dvb.monitor(...)` → `from dvb import Client` + `Client(user_agent="...").monitor(...)` +- All method signatures remain the same + ## Migrating from 1.x -dvb 2.0 is a complete rewrite with breaking changes: +dvb 2.0 was a complete rewrite with breaking changes: - All functions return frozen dataclasses instead of dicts/lists - Functions raise `APIError`/`ConnectionError` instead of printing errors and returning `None` diff --git a/dvb/__init__.py b/dvb/__init__.py index ec954fb..12dea3f 100644 --- a/dvb/__init__.py +++ b/dvb/__init__.py @@ -2,19 +2,9 @@ An unofficial Python module for querying Dresden's public transport system (VVO/DVB). """ -__version__ = "2.0.0" +__version__ = "3.0.0" -from .dvb import ( - address, - earlier_later, - find, - lines, - monitor, - pins, - route, - route_changes, - trip_details, -) +from .dvb import Client from .exceptions import APIError, ConnectionError, DVBError from .models import ( Coords, @@ -31,16 +21,8 @@ ) __all__ = [ - # Functions - "address", - "earlier_later", - "find", - "lines", - "monitor", - "pins", - "route", - "route_changes", - "trip_details", + # Client + "Client", # Exceptions "APIError", "ConnectionError", diff --git a/dvb/dvb.py b/dvb/dvb.py index d3a1888..4133c2c 100644 --- a/dvb/dvb.py +++ b/dvb/dvb.py @@ -28,69 +28,13 @@ _TIMEOUT = 15 -def _post(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: - """POST to a WebAPI endpoint and return the parsed JSON response.""" - payload["format"] = "json" - try: - r = requests.post( - f"{BASE_URL}/{endpoint}", - json=payload, - headers={"Content-Type": "application/json; charset=UTF-8"}, - timeout=_TIMEOUT, - ) - r.raise_for_status() - except requests.RequestException as e: - raise DVBConnectionError(str(e)) from e - - data: dict[str, Any] = r.json() - status = data.get("Status", {}).get("Code", "") - if status != "Ok": - msg = f"API returned status: {status}" - raise APIError(msg) - return data - - -def _get(endpoint: str, params: dict[str, Any]) -> dict[str, Any]: - """GET from a WebAPI endpoint and return the parsed JSON response.""" - params["format"] = "json" - try: - r = requests.get( - f"{BASE_URL}/{endpoint}", - params=params, - timeout=_TIMEOUT, - ) - r.raise_for_status() - except requests.RequestException as e: - raise DVBConnectionError(str(e)) from e - - data: dict[str, Any] = r.json() - status = data.get("Status", {}).get("Code", "") - if status != "Ok": - msg = f"API returned status: {status}" - raise APIError(msg) - return data - - -def _resolve_stop_id(stop: str) -> str: - """Resolve a stop name to its numeric ID. Returns as-is if already numeric.""" - if stop.isdigit(): - return stop - results = find(stop) - if not isinstance(results, list) or not results: - msg = f"No stops found for query: {stop}" - raise APIError(msg) - return results[0].id - - def _parse_platform(data: dict[str, Any] | None) -> Platform | None: - """Parse a Platform object from API response data.""" if not data: return None return Platform(name=data.get("Name", ""), type=data.get("Type", "")) def _parse_regular_stop(data: dict[str, Any]) -> RegularStop: - """Parse a RegularStop from a trip/departure response.""" coords = None lat = data.get("Latitude") lng = data.get("Longitude") @@ -117,7 +61,6 @@ def _parse_regular_stop(data: dict[str, Any]) -> RegularStop: def _try_parse_date(s: str | None) -> datetime | None: - """Parse a date string, returning None if input is None or invalid.""" if not s: return None try: @@ -127,7 +70,6 @@ def _try_parse_date(s: str | None) -> datetime | None: def _parse_route(route_data: dict[str, Any], session_id: str | None = None) -> Route: - """Parse a Route from a trips response.""" legs: list[PartialRoute] = [] for pr in route_data.get("PartialRoutes", []): mot = pr.get("Mot", {}) @@ -164,7 +106,6 @@ def _parse_route(route_data: dict[str, Any], session_id: str | None = None) -> R def _parse_map_data(map_str: str) -> list[Coords]: - """Parse pipe-delimited GK4 map data into Coords list.""" parts = map_str.split("|") # First element is the transport mode, then alternating lat/lng pairs coords: list[Coords] = [] @@ -180,202 +121,7 @@ def _parse_map_data(map_str: str) -> list[Coords]: return coords -# --- Public API functions --- - - -def find(query: str, *, raw: bool = False) -> list[Stop] | dict[str, Any]: - """Find stops by name. - - Args: - query: Search query string. - raw: If True, return the raw API response dict. - - Returns: - List of Stop objects, or raw dict if raw=True. - - Raises: - APIError: If the API returns an error status. - ConnectionError: If the request fails. - """ - data = _get("tr/pointfinder", {"query": query, "stopsOnly": "true"}) - - if raw: - return data - - points = data.get("Points", []) - return [parse_point(p) for p in points if p] - - -def monitor( - stop: str, - *, - offset: int = 0, - limit: int = 10, - raw: bool = False, -) -> list[Departure] | dict[str, Any]: - """Get departures from a stop. - - Args: - stop: Stop name or numeric stop ID. - offset: Time offset in minutes (not currently used by WebAPI, reserved). - limit: Maximum number of departures to return. - raw: If True, return the raw API response dict. - - Returns: - List of Departure objects, or raw dict if raw=True. - - Raises: - APIError: If the API returns an error or stop not found. - ConnectionError: If the request fails. - """ - stop_id = _resolve_stop_id(stop) - payload: dict[str, Any] = {"stopid": stop_id, "limit": limit} - if offset: - now = datetime.now(tz=timezone.utc) - payload["time"] = now.isoformat() - - data = _post("dm", payload) - - if raw: - return data - - departures: list[Departure] = [] - for dep in data.get("Departures", []): - scheduled = parse_date(dep["ScheduledTime"]) - real_time = _try_parse_date(dep.get("RealTime")) - - departures.append( - Departure( - id=dep.get("Id", ""), - line=dep.get("LineName", ""), - direction=dep.get("Direction", ""), - scheduled=scheduled, - real_time=real_time, - state=dep.get("State", ""), - platform=_parse_platform(dep.get("Platform")), - mode=dep.get("Mot", ""), - occupancy=dep.get("Occupancy", "Unknown"), - ) - ) - - return departures - - -def route( - origin: str, - destination: str, - *, - time: datetime | None = None, - arrival: bool = False, - raw: bool = False, -) -> list[Route] | dict[str, Any]: - """Plan a trip between two stops. - - Args: - origin: Origin stop name or ID. - destination: Destination stop name or ID. - time: Departure or arrival time. Defaults to now. - arrival: If True, interpret time as arrival time. - raw: If True, return the raw API response dict. - - Returns: - List of Route objects, or raw dict if raw=True. - - Raises: - APIError: If the API returns an error or stops not found. - ConnectionError: If the request fails. - """ - origin_id = _resolve_stop_id(origin) - dest_id = _resolve_stop_id(destination) - - if time is None: - time = datetime.now(tz=timezone.utc) - - payload: dict[str, Any] = { - "origin": origin_id, - "destination": dest_id, - "time": time.isoformat(), - "isarrivaltime": arrival, - "shorttermchanges": True, - } - - data = _post("tr/trips", payload) - - if raw: - return data - - session_id = data.get("SessionId") - return [_parse_route(r, session_id) for r in data.get("Routes", [])] - - -def pins( - sw_lat: float, - sw_lng: float, - ne_lat: float, - ne_lng: float, - *, - pin_types: tuple[str, ...] = ("Stop",), - raw: bool = False, -) -> list[Pin] | dict[str, Any]: - """Get map pins within a bounding box. - - Args: - sw_lat: Southwest latitude (WGS84). - sw_lng: Southwest longitude (WGS84). - ne_lat: Northeast latitude (WGS84). - ne_lng: Northeast longitude (WGS84). - pin_types: Types of pins to include (e.g. "Stop", "Platform", "Poi"). - raw: If True, return the raw API response dict. - - Returns: - List of Pin objects, or raw dict if raw=True. - - Raises: - APIError: If the API returns an error status. - ConnectionError: If the request fails. - """ - sw_gk4_lat, sw_gk4_lng = coords_wgs_to_gk4(sw_lat, sw_lng) - ne_gk4_lat, ne_gk4_lng = coords_wgs_to_gk4(ne_lat, ne_lng) - - payload: dict[str, Any] = { - "swlat": str(sw_gk4_lat), - "swlng": str(sw_gk4_lng), - "nelat": str(ne_gk4_lat), - "nelng": str(ne_gk4_lng), - "pintypes": list(pin_types), - } - - data = _post("map/pins", payload) - - if raw: - return data - - result: list[Pin] = [] - for pin_str in data.get("Pins", []): - if not pin_str: - continue - parts = pin_str.split("|") - pin_id = parts[0] if parts else "" - city = parts[2] if len(parts) > 2 else "" - name = parts[3] if len(parts) > 3 else "" - - coords = None - if len(parts) > 5: - lat_str = parts[4] - lng_str = parts[5] - if lat_str and lng_str and lat_str != "0" and lng_str != "0": - coords = coords_gk4_to_wgs(int(lat_str), int(lng_str)) - - # Determine type from ID prefix - pin_type = _pin_type_from_id(pin_id) - - result.append(Pin(id=pin_id, name=name, city=city, coords=coords, type=pin_type)) - - return result - - def _pin_type_from_id(pin_id: str) -> str: - """Determine pin type from its ID prefix.""" if pin_id.isdigit(): return "Stop" prefixes = { @@ -393,192 +139,444 @@ def _pin_type_from_id(pin_id: str) -> str: return "Unknown" -def address( - lat: float, - lng: float, - *, - raw: bool = False, -) -> Stop | None | dict[str, Any]: - """Reverse geocode coordinates to the nearest stop. - - Args: - lat: Latitude (WGS84). - lng: Longitude (WGS84). - raw: If True, return the raw API response dict. - - Returns: - The nearest Stop, None if not found, or raw dict if raw=True. - - Raises: - APIError: If the API returns an error status. - ConnectionError: If the request fails. - """ - gk4_lat, gk4_lng = coords_wgs_to_gk4(lat, lng) - data = _get( - "tr/pointfinder", - {"query": f"coord:{gk4_lng}:{gk4_lat}", "assignedstops": "true"}, - ) - - if raw: - return data - - points = data.get("Points", []) - if not points: - return None - - return parse_point(points[0]) - - -def lines( - stop: str, - *, - raw: bool = False, -) -> list[Line] | dict[str, Any]: - """Get lines servicing a stop. +class Client: + """DVB/VVO API client. Args: - stop: Stop name or numeric stop ID. - raw: If True, return the raw API response dict. - - Returns: - List of Line objects, or raw dict if raw=True. - - Raises: - APIError: If the API returns an error or stop not found. - ConnectionError: If the request fails. + user_agent: A User-Agent string identifying your project and providing + contact details, e.g. ``"my-app/1.0 (me@example.com)"``. """ - stop_id = _resolve_stop_id(stop) - data = _post("stt/lines", {"stopid": stop_id}) - if raw: - return data + def __init__(self, user_agent: str) -> None: + if not user_agent or not user_agent.strip(): + msg = "user_agent must be a non-empty string identifying your project" + raise ValueError(msg) + self._session = requests.Session() + self._session.headers["User-Agent"] = user_agent - result: list[Line] = [] - for line_data in data.get("Lines", []): - directions = [d["Name"] for d in line_data.get("Directions", [])] - result.append( - Line( - name=line_data.get("Name", ""), - mode=line_data.get("Mot", ""), - directions=directions, + def _post(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: + payload["format"] = "json" + try: + r = self._session.post( + f"{BASE_URL}/{endpoint}", + json=payload, + headers={"Content-Type": "application/json; charset=UTF-8"}, + timeout=_TIMEOUT, ) - ) - - return result - - -def route_changes(*, raw: bool = False) -> list[RouteChange] | dict[str, Any]: - """Get current route changes and disruptions. - - Args: - raw: If True, return the raw API response dict. - - Returns: - List of RouteChange objects, or raw dict if raw=True. - - Raises: - APIError: If the API returns an error status. - ConnectionError: If the request fails. - """ - data = _post("rc", {"shortterm": True}) - - if raw: + r.raise_for_status() + except requests.RequestException as e: + raise DVBConnectionError(str(e)) from e + + data: dict[str, Any] = r.json() + status = data.get("Status", {}).get("Code", "") + if status != "Ok": + msg = f"API returned status: {status}" + raise APIError(msg) return data - result: list[RouteChange] = [] - for change in data.get("Changes", []): - validity_periods = [] - for vp in change.get("ValidityPeriods", []): - begin = _try_parse_date(vp.get("Begin")) - end = _try_parse_date(vp.get("End")) - if begin and end: - validity_periods.append(ValidityPeriod(begin=begin, end=end)) - - result.append( - RouteChange( - id=change.get("Id", ""), - title=change.get("Title", ""), - description=change.get("Description", ""), - type=change.get("Type", ""), - validity_periods=validity_periods, - lines=change.get("LineIds", []), + def _get(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any]: + params["format"] = "json" + try: + r = self._session.get( + f"{BASE_URL}/{endpoint}", + params=params, + timeout=_TIMEOUT, ) - ) - - return result - - -def trip_details( - trip_id: str, - time: datetime, - stop_id: str, - *, - raw: bool = False, -) -> list[RegularStop] | dict[str, Any]: - """Get all stops for a specific trip/departure. - - Args: - trip_id: The departure ID from a monitor response. - time: Departure time (e.g. from Departure.scheduled). - stop_id: ID of a stop on the route. - raw: If True, return the raw API response dict. - - Returns: - List of RegularStop objects, or raw dict if raw=True. - - Raises: - APIError: If the API returns an error status. - ConnectionError: If the request fails. - """ - data = _post( - "dm/trip", - {"tripid": trip_id, "time": format_date(time), "stopid": stop_id, "mapdata": True}, - ) - - if raw: + r.raise_for_status() + except requests.RequestException as e: + raise DVBConnectionError(str(e)) from e + + data: dict[str, Any] = r.json() + status = data.get("Status", {}).get("Code", "") + if status != "Ok": + msg = f"API returned status: {status}" + raise APIError(msg) return data - return [_parse_regular_stop(s) for s in data.get("Stops", [])] - + def _resolve_stop_id(self, stop: str) -> str: + if stop.isdigit(): + return stop + results = self.find(stop) + if not isinstance(results, list) or not results: + msg = f"No stops found for query: {stop}" + raise APIError(msg) + return results[0].id + + def find(self, query: str, *, raw: bool = False) -> list[Stop] | dict[str, Any]: + """Find stops by name. + + Args: + query: Search query string. + raw: If True, return the raw API response dict. + + Returns: + List of Stop objects, or raw dict if raw=True. + + Raises: + APIError: If the API returns an error status. + ConnectionError: If the request fails. + """ + data = self._get("tr/pointfinder", {"query": query, "stopsOnly": "true"}) + + if raw: + return data + + points = data.get("Points", []) + return [parse_point(p) for p in points if p] + + def monitor( + self, + stop: str, + *, + offset: int = 0, + limit: int = 10, + raw: bool = False, + ) -> list[Departure] | dict[str, Any]: + """Get departures from a stop. + + Args: + stop: Stop name or numeric stop ID. + offset: Time offset in minutes (not currently used by WebAPI, reserved). + limit: Maximum number of departures to return. + raw: If True, return the raw API response dict. + + Returns: + List of Departure objects, or raw dict if raw=True. + + Raises: + APIError: If the API returns an error or stop not found. + ConnectionError: If the request fails. + """ + stop_id = self._resolve_stop_id(stop) + payload: dict[str, Any] = {"stopid": stop_id, "limit": limit} + if offset: + now = datetime.now(tz=timezone.utc) + payload["time"] = now.isoformat() + + data = self._post("dm", payload) + + if raw: + return data + + departures: list[Departure] = [] + for dep in data.get("Departures", []): + scheduled = parse_date(dep["ScheduledTime"]) + real_time = _try_parse_date(dep.get("RealTime")) + + departures.append( + Departure( + id=dep.get("Id", ""), + line=dep.get("LineName", ""), + direction=dep.get("Direction", ""), + scheduled=scheduled, + real_time=real_time, + state=dep.get("State", ""), + platform=_parse_platform(dep.get("Platform")), + mode=dep.get("Mot", ""), + occupancy=dep.get("Occupancy", "Unknown"), + ) + ) -def earlier_later( - origin: str, - destination: str, - session_id: str, - *, - previous: bool = True, - raw: bool = False, -) -> list[Route] | dict[str, Any]: - """Paginate trip results using a session ID from a previous route() call. + return departures + + def route( + self, + origin: str, + destination: str, + *, + time: datetime | None = None, + arrival: bool = False, + raw: bool = False, + ) -> list[Route] | dict[str, Any]: + """Plan a trip between two stops. + + Args: + origin: Origin stop name or ID. + destination: Destination stop name or ID. + time: Departure or arrival time. Defaults to now. + arrival: If True, interpret time as arrival time. + raw: If True, return the raw API response dict. + + Returns: + List of Route objects, or raw dict if raw=True. + + Raises: + APIError: If the API returns an error or stops not found. + ConnectionError: If the request fails. + """ + origin_id = self._resolve_stop_id(origin) + dest_id = self._resolve_stop_id(destination) + + if time is None: + time = datetime.now(tz=timezone.utc) + + payload: dict[str, Any] = { + "origin": origin_id, + "destination": dest_id, + "time": time.isoformat(), + "isarrivaltime": arrival, + "shorttermchanges": True, + } + + data = self._post("tr/trips", payload) + + if raw: + return data + + session_id = data.get("SessionId") + return [_parse_route(r, session_id) for r in data.get("Routes", [])] + + def pins( + self, + sw_lat: float, + sw_lng: float, + ne_lat: float, + ne_lng: float, + *, + pin_types: tuple[str, ...] = ("Stop",), + raw: bool = False, + ) -> list[Pin] | dict[str, Any]: + """Get map pins within a bounding box. + + Args: + sw_lat: Southwest latitude (WGS84). + sw_lng: Southwest longitude (WGS84). + ne_lat: Northeast latitude (WGS84). + ne_lng: Northeast longitude (WGS84). + pin_types: Types of pins to include (e.g. "Stop", "Platform", "Poi"). + raw: If True, return the raw API response dict. + + Returns: + List of Pin objects, or raw dict if raw=True. + + Raises: + APIError: If the API returns an error status. + ConnectionError: If the request fails. + """ + sw_gk4_lat, sw_gk4_lng = coords_wgs_to_gk4(sw_lat, sw_lng) + ne_gk4_lat, ne_gk4_lng = coords_wgs_to_gk4(ne_lat, ne_lng) + + payload: dict[str, Any] = { + "swlat": str(sw_gk4_lat), + "swlng": str(sw_gk4_lng), + "nelat": str(ne_gk4_lat), + "nelng": str(ne_gk4_lng), + "pintypes": list(pin_types), + } + + data = self._post("map/pins", payload) + + if raw: + return data + + result: list[Pin] = [] + for pin_str in data.get("Pins", []): + if not pin_str: + continue + parts = pin_str.split("|") + pin_id = parts[0] if parts else "" + city = parts[2] if len(parts) > 2 else "" + name = parts[3] if len(parts) > 3 else "" + + coords = None + if len(parts) > 5: + lat_str = parts[4] + lng_str = parts[5] + if lat_str and lng_str and lat_str != "0" and lng_str != "0": + coords = coords_gk4_to_wgs(int(lat_str), int(lng_str)) + + pin_type = _pin_type_from_id(pin_id) + + result.append(Pin(id=pin_id, name=name, city=city, coords=coords, type=pin_type)) + + return result + + def address( + self, + lat: float, + lng: float, + *, + raw: bool = False, + ) -> Stop | None | dict[str, Any]: + """Reverse geocode coordinates to the nearest stop. + + Args: + lat: Latitude (WGS84). + lng: Longitude (WGS84). + raw: If True, return the raw API response dict. + + Returns: + The nearest Stop, None if not found, or raw dict if raw=True. + + Raises: + APIError: If the API returns an error status. + ConnectionError: If the request fails. + """ + gk4_lat, gk4_lng = coords_wgs_to_gk4(lat, lng) + data = self._get( + "tr/pointfinder", + {"query": f"coord:{gk4_lng}:{gk4_lat}", "assignedstops": "true"}, + ) - Args: - origin: Origin stop name or ID. - destination: Destination stop name or ID. - session_id: Session ID from a previous route() response. - previous: If True, get earlier connections; if False, get later ones. - raw: If True, return the raw API response dict. - - Returns: - List of Route objects, or raw dict if raw=True. - - Raises: - APIError: If the API returns an error or stops not found. - ConnectionError: If the request fails. - """ - origin_id = _resolve_stop_id(origin) - dest_id = _resolve_stop_id(destination) - - payload: dict[str, Any] = { - "origin": origin_id, - "destination": dest_id, - "sessionId": session_id, - "previous": previous, - } + if raw: + return data + + points = data.get("Points", []) + if not points: + return None + + return parse_point(points[0]) + + def lines( + self, + stop: str, + *, + raw: bool = False, + ) -> list[Line] | dict[str, Any]: + """Get lines servicing a stop. + + Args: + stop: Stop name or numeric stop ID. + raw: If True, return the raw API response dict. + + Returns: + List of Line objects, or raw dict if raw=True. + + Raises: + APIError: If the API returns an error or stop not found. + ConnectionError: If the request fails. + """ + stop_id = self._resolve_stop_id(stop) + data = self._post("stt/lines", {"stopid": stop_id}) + + if raw: + return data + + result: list[Line] = [] + for line_data in data.get("Lines", []): + directions = [d["Name"] for d in line_data.get("Directions", [])] + result.append( + Line( + name=line_data.get("Name", ""), + mode=line_data.get("Mot", ""), + directions=directions, + ) + ) - data = _post("tr/prevnext", payload) + return result + + def route_changes(self, *, raw: bool = False) -> list[RouteChange] | dict[str, Any]: + """Get current route changes and disruptions. + + Args: + raw: If True, return the raw API response dict. + + Returns: + List of RouteChange objects, or raw dict if raw=True. + + Raises: + APIError: If the API returns an error status. + ConnectionError: If the request fails. + """ + data = self._post("rc", {"shortterm": True}) + + if raw: + return data + + result: list[RouteChange] = [] + for change in data.get("Changes", []): + validity_periods = [] + for vp in change.get("ValidityPeriods", []): + begin = _try_parse_date(vp.get("Begin")) + end = _try_parse_date(vp.get("End")) + if begin and end: + validity_periods.append(ValidityPeriod(begin=begin, end=end)) + + result.append( + RouteChange( + id=change.get("Id", ""), + title=change.get("Title", ""), + description=change.get("Description", ""), + type=change.get("Type", ""), + validity_periods=validity_periods, + lines=change.get("LineIds", []), + ) + ) - if raw: - return data + return result + + def trip_details( + self, + trip_id: str, + time: datetime, + stop_id: str, + *, + raw: bool = False, + ) -> list[RegularStop] | dict[str, Any]: + """Get all stops for a specific trip/departure. + + Args: + trip_id: The departure ID from a monitor response. + time: Departure time (e.g. from Departure.scheduled). + stop_id: ID of a stop on the route. + raw: If True, return the raw API response dict. + + Returns: + List of RegularStop objects, or raw dict if raw=True. + + Raises: + APIError: If the API returns an error status. + ConnectionError: If the request fails. + """ + data = self._post( + "dm/trip", + {"tripid": trip_id, "time": format_date(time), "stopid": stop_id, "mapdata": True}, + ) - new_session_id = data.get("SessionId") - return [_parse_route(r, new_session_id) for r in data.get("Routes", [])] + if raw: + return data + + return [_parse_regular_stop(s) for s in data.get("Stops", [])] + + def earlier_later( + self, + origin: str, + destination: str, + session_id: str, + *, + previous: bool = True, + raw: bool = False, + ) -> list[Route] | dict[str, Any]: + """Paginate trip results using a session ID from a previous route() call. + + Args: + origin: Origin stop name or ID. + destination: Destination stop name or ID. + session_id: Session ID from a previous route() response. + previous: If True, get earlier connections; if False, get later ones. + raw: If True, return the raw API response dict. + + Returns: + List of Route objects, or raw dict if raw=True. + + Raises: + APIError: If the API returns an error or stops not found. + ConnectionError: If the request fails. + """ + origin_id = self._resolve_stop_id(origin) + dest_id = self._resolve_stop_id(destination) + + payload: dict[str, Any] = { + "origin": origin_id, + "destination": dest_id, + "sessionId": session_id, + "previous": previous, + } + + data = self._post("tr/prevnext", payload) + + if raw: + return data + + new_session_id = data.get("SessionId") + return [_parse_route(r, new_session_id) for r in data.get("Routes", [])] diff --git a/tests/conftest.py b/tests/conftest.py index 3a35de3..ea6e305 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,8 @@ import pytest import responses +from dvb import Client + FIXTURES = Path(__file__).parent / "fixtures" BASE_URL = "https://webapi.vvo-online.de" @@ -23,6 +25,11 @@ def mocked_responses() -> responses.RequestsMock: yield rsps +@pytest.fixture() +def client() -> Client: + return Client(user_agent="dvb-test-suite/1.0 (test@test)") + + def mock_get( rsps: responses.RequestsMock, endpoint: str, diff --git a/tests/test_address.py b/tests/test_address.py index 9581b5b..14db7cd 100644 --- a/tests/test_address.py +++ b/tests/test_address.py @@ -1,12 +1,12 @@ from __future__ import annotations -import dvb +from dvb import Client from .conftest import mock_get class TestAddress: - def test_returns_stop(self, mocked_responses: object) -> None: + def test_returns_stop(self, mocked_responses: object, client: Client) -> None: mock_get( # type: ignore[arg-type] mocked_responses, "tr/pointfinder", @@ -16,22 +16,22 @@ def test_returns_stop(self, mocked_responses: object) -> None: "Points": ["33000742|||Helmholtzstraße|5655904|4621157|42||"], }, ) - result = dvb.address(51.03, 13.73) + result = client.address(51.03, 13.73) assert result is not None assert not isinstance(result, dict) assert result.name == "Helmholtzstraße" assert result.id == "33000742" - def test_returns_none_when_empty(self, mocked_responses: object) -> None: + def test_returns_none_when_empty(self, mocked_responses: object, client: Client) -> None: mock_get( # type: ignore[arg-type] mocked_responses, "tr/pointfinder", body={"PointStatus": "List", "Status": {"Code": "Ok"}, "Points": []}, ) - result = dvb.address(0.0, 0.0) + result = client.address(0.0, 0.0) assert result is None - def test_raw_returns_dict(self, mocked_responses: object) -> None: + def test_raw_returns_dict(self, mocked_responses: object, client: Client) -> None: mock_get( # type: ignore[arg-type] mocked_responses, "tr/pointfinder", @@ -41,6 +41,6 @@ def test_raw_returns_dict(self, mocked_responses: object) -> None: "Points": ["33000742|||Helmholtzstraße|5655904|4621157|42||"], }, ) - result = dvb.address(51.03, 13.73, raw=True) + result = client.address(51.03, 13.73, raw=True) assert isinstance(result, dict) assert "Points" in result diff --git a/tests/test_find.py b/tests/test_find.py index de88137..7bb8265 100644 --- a/tests/test_find.py +++ b/tests/test_find.py @@ -2,16 +2,16 @@ import pytest -import dvb +from dvb import Client from dvb.exceptions import APIError, ConnectionError from .conftest import mock_get class TestFind: - def test_parses_stops(self, mocked_responses: object) -> None: + def test_parses_stops(self, mocked_responses: object, client: Client) -> None: mock_get(mocked_responses, "tr/pointfinder", fixture="pointfinder.json") # type: ignore[arg-type] - results = dvb.find("Helmholtz") + results = client.find("Helmholtz") assert isinstance(results, list) assert len(results) == 3 @@ -22,46 +22,46 @@ def test_parses_stops(self, mocked_responses: object) -> None: assert stop.coords is not None assert abs(stop.coords.lat - 51.0) < 0.1 - def test_stop_with_city(self, mocked_responses: object) -> None: + def test_stop_with_city(self, mocked_responses: object, client: Client) -> None: mock_get(mocked_responses, "tr/pointfinder", fixture="pointfinder.json") # type: ignore[arg-type] - results = dvb.find("Helmholtz") + results = client.find("Helmholtz") assert isinstance(results, list) assert results[1].city == "Chemnitz" assert results[1].name == "Helmholtzstr" - def test_stop_without_coords(self, mocked_responses: object) -> None: + def test_stop_without_coords(self, mocked_responses: object, client: Client) -> None: mock_get(mocked_responses, "tr/pointfinder", fixture="pointfinder.json") # type: ignore[arg-type] - results = dvb.find("Helmholtz") + results = client.find("Helmholtz") assert isinstance(results, list) assert results[2].coords is None assert results[2].city == "Bonn" - def test_raw_returns_dict(self, mocked_responses: object) -> None: + def test_raw_returns_dict(self, mocked_responses: object, client: Client) -> None: mock_get(mocked_responses, "tr/pointfinder", fixture="pointfinder.json") # type: ignore[arg-type] - result = dvb.find("Helmholtz", raw=True) + result = client.find("Helmholtz", raw=True) assert isinstance(result, dict) assert "Points" in result - def test_empty_results(self, mocked_responses: object) -> None: + def test_empty_results(self, mocked_responses: object, client: Client) -> None: mock_get( # type: ignore[arg-type] mocked_responses, "tr/pointfinder", body={"PointStatus": "List", "Status": {"Code": "Ok"}, "Points": []}, ) - results = dvb.find("nonexistent") + results = client.find("nonexistent") assert isinstance(results, list) assert len(results) == 0 - def test_api_error(self, mocked_responses: object) -> None: + def test_api_error(self, mocked_responses: object, client: Client) -> None: mock_get( # type: ignore[arg-type] mocked_responses, "tr/pointfinder", body={"Status": {"Code": "InvalidRequest"}}, ) with pytest.raises(APIError, match="InvalidRequest"): - dvb.find("test") + client.find("test") - def test_connection_error(self, mocked_responses: object) -> None: + def test_connection_error(self, mocked_responses: object, client: Client) -> None: import responses as responses_lib mocked_responses.add( # type: ignore[attr-defined] @@ -70,4 +70,4 @@ def test_connection_error(self, mocked_responses: object) -> None: body=ConnectionError("timeout"), ) with pytest.raises(ConnectionError): - dvb.find("test") + client.find("test") diff --git a/tests/test_lines.py b/tests/test_lines.py index fdad7b5..98c8d78 100644 --- a/tests/test_lines.py +++ b/tests/test_lines.py @@ -1,15 +1,15 @@ from __future__ import annotations -import dvb +from dvb import Client from dvb.models import Line from .conftest import mock_get, mock_post class TestLines: - def test_parses_lines(self, mocked_responses: object) -> None: + def test_parses_lines(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "stt/lines", fixture="lines.json") # type: ignore[arg-type] - results = dvb.lines("33000742") + results = client.lines("33000742") assert isinstance(results, list) assert len(results) == 2 @@ -21,24 +21,24 @@ def test_parses_lines(self, mocked_responses: object) -> None: assert "Dresden Wilder Mann" in line.directions assert "Dresden Coschütz" in line.directions - def test_second_line(self, mocked_responses: object) -> None: + def test_second_line(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "stt/lines", fixture="lines.json") # type: ignore[arg-type] - results = dvb.lines("33000742") + results = client.lines("33000742") assert isinstance(results, list) line = results[1] assert line.name == "66" assert line.mode == "CityBus" assert len(line.directions) == 1 - def test_resolves_stop_name(self, mocked_responses: object) -> None: + def test_resolves_stop_name(self, mocked_responses: object, client: Client) -> None: mock_get(mocked_responses, "tr/pointfinder", fixture="pointfinder.json") # type: ignore[arg-type] mock_post(mocked_responses, "stt/lines", fixture="lines.json") # type: ignore[arg-type] - results = dvb.lines("Helmholtzstraße") + results = client.lines("Helmholtzstraße") assert isinstance(results, list) assert len(results) == 2 - def test_raw_returns_dict(self, mocked_responses: object) -> None: + def test_raw_returns_dict(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "stt/lines", fixture="lines.json") # type: ignore[arg-type] - result = dvb.lines("33000742", raw=True) + result = client.lines("33000742", raw=True) assert isinstance(result, dict) assert "Lines" in result diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 084dcbc..7f2ba4c 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -1,15 +1,15 @@ from __future__ import annotations -import dvb +from dvb import Client from dvb.models import Departure from .conftest import mock_get, mock_post class TestMonitor: - def test_parses_departures(self, mocked_responses: object) -> None: + def test_parses_departures(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm", fixture="departure_monitor.json") # type: ignore[arg-type] - results = dvb.monitor("33000742") + results = client.monitor("33000742") assert isinstance(results, list) assert len(results) == 2 @@ -24,36 +24,36 @@ def test_parses_departures(self, mocked_responses: object) -> None: assert dep.platform.name == "1" assert dep.platform.type == "Platform" - def test_scheduled_time_parsed(self, mocked_responses: object) -> None: + def test_scheduled_time_parsed(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm", fixture="departure_monitor.json") # type: ignore[arg-type] - results = dvb.monitor("33000742") + results = client.monitor("33000742") assert isinstance(results, list) dep = results[0] assert dep.scheduled.year == 2017 assert dep.real_time is not None - def test_departure_without_realtime(self, mocked_responses: object) -> None: + def test_departure_without_realtime(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm", fixture="departure_monitor.json") # type: ignore[arg-type] - results = dvb.monitor("33000742") + results = client.monitor("33000742") assert isinstance(results, list) dep = results[1] assert dep.line == "8" assert dep.real_time is None - def test_resolves_stop_name(self, mocked_responses: object) -> None: + def test_resolves_stop_name(self, mocked_responses: object, client: Client) -> None: mock_get(mocked_responses, "tr/pointfinder", fixture="pointfinder.json") # type: ignore[arg-type] mock_post(mocked_responses, "dm", fixture="departure_monitor.json") # type: ignore[arg-type] - results = dvb.monitor("Helmholtzstraße") + results = client.monitor("Helmholtzstraße") assert isinstance(results, list) assert len(results) == 2 - def test_raw_returns_dict(self, mocked_responses: object) -> None: + def test_raw_returns_dict(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm", fixture="departure_monitor.json") # type: ignore[arg-type] - result = dvb.monitor("33000742", raw=True) + result = client.monitor("33000742", raw=True) assert isinstance(result, dict) assert "Departures" in result - def test_empty_departures(self, mocked_responses: object) -> None: + def test_empty_departures(self, mocked_responses: object, client: Client) -> None: mock_post( # type: ignore[arg-type] mocked_responses, "dm", @@ -64,6 +64,6 @@ def test_empty_departures(self, mocked_responses: object) -> None: "Departures": [], }, ) - results = dvb.monitor("33000742") + results = client.monitor("33000742") assert isinstance(results, list) assert len(results) == 0 diff --git a/tests/test_pins.py b/tests/test_pins.py index aab8701..9fec9af 100644 --- a/tests/test_pins.py +++ b/tests/test_pins.py @@ -1,21 +1,21 @@ from __future__ import annotations -import dvb +from dvb import Client from dvb.models import Pin from .conftest import mock_post class TestPins: - def test_parses_pins(self, mocked_responses: object) -> None: + def test_parses_pins(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "map/pins", fixture="pins.json") # type: ignore[arg-type] - results = dvb.pins(51.0, 13.7, 51.1, 13.8) + results = client.pins(51.0, 13.7, 51.1, 13.8) assert isinstance(results, list) assert len(results) == 3 - def test_stop_pin(self, mocked_responses: object) -> None: + def test_stop_pin(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "map/pins", fixture="pins.json") # type: ignore[arg-type] - results = dvb.pins(51.0, 13.7, 51.1, 13.8) + results = client.pins(51.0, 13.7, 51.1, 13.8) assert isinstance(results, list) pin = results[0] @@ -26,31 +26,31 @@ def test_stop_pin(self, mocked_responses: object) -> None: assert pin.type == "Stop" assert pin.coords is not None - def test_platform_pin(self, mocked_responses: object) -> None: + def test_platform_pin(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "map/pins", fixture="pins.json") # type: ignore[arg-type] - results = dvb.pins(51.0, 13.7, 51.1, 13.8) + results = client.pins(51.0, 13.7, 51.1, 13.8) assert isinstance(results, list) assert results[1].type == "Platform" assert results[1].id == "pf:1234" - def test_park_and_ride_pin(self, mocked_responses: object) -> None: + def test_park_and_ride_pin(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "map/pins", fixture="pins.json") # type: ignore[arg-type] - results = dvb.pins(51.0, 13.7, 51.1, 13.8) + results = client.pins(51.0, 13.7, 51.1, 13.8) assert isinstance(results, list) assert results[2].type == "ParkAndRide" - def test_raw_returns_dict(self, mocked_responses: object) -> None: + def test_raw_returns_dict(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "map/pins", fixture="pins.json") # type: ignore[arg-type] - result = dvb.pins(51.0, 13.7, 51.1, 13.8, raw=True) + result = client.pins(51.0, 13.7, 51.1, 13.8, raw=True) assert isinstance(result, dict) assert "Pins" in result - def test_empty_pins(self, mocked_responses: object) -> None: + def test_empty_pins(self, mocked_responses: object, client: Client) -> None: mock_post( # type: ignore[arg-type] mocked_responses, "map/pins", body={"Pins": [], "Status": {"Code": "Ok"}}, ) - results = dvb.pins(51.0, 13.7, 51.1, 13.8) + results = client.pins(51.0, 13.7, 51.1, 13.8) assert isinstance(results, list) assert len(results) == 0 diff --git a/tests/test_route.py b/tests/test_route.py index bcc4765..2f4b23a 100644 --- a/tests/test_route.py +++ b/tests/test_route.py @@ -1,15 +1,15 @@ from __future__ import annotations -import dvb +from dvb import Client from dvb.models import Route from .conftest import mock_get, mock_post class TestRoute: - def test_parses_routes(self, mocked_responses: object) -> None: + def test_parses_routes(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "tr/trips", fixture="trips.json") # type: ignore[arg-type] - results = dvb.route("33000028", "33000016") + results = client.route("33000028", "33000016") assert isinstance(results, list) assert len(results) == 1 @@ -22,9 +22,9 @@ def test_parses_routes(self, mocked_responses: object) -> None: assert r.cancelled is False assert r.session_id == "367417461:efa4" - def test_parses_legs(self, mocked_responses: object) -> None: + def test_parses_legs(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "tr/trips", fixture="trips.json") # type: ignore[arg-type] - results = dvb.route("33000028", "33000016") + results = client.route("33000028", "33000016") assert isinstance(results, list) leg = results[0].legs[0] @@ -35,9 +35,9 @@ def test_parses_legs(self, mocked_responses: object) -> None: assert leg.cancelled is False assert leg.changeover_endangered is False - def test_parses_regular_stops(self, mocked_responses: object) -> None: + def test_parses_regular_stops(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "tr/trips", fixture="trips.json") # type: ignore[arg-type] - results = dvb.route("33000028", "33000016") + results = client.route("33000028", "33000016") assert isinstance(results, list) stops = results[0].legs[0].stops @@ -51,9 +51,9 @@ def test_parses_regular_stops(self, mocked_responses: object) -> None: assert stops[0].arrival is not None assert stops[0].departure is not None - def test_parses_path(self, mocked_responses: object) -> None: + def test_parses_path(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "tr/trips", fixture="trips.json") # type: ignore[arg-type] - results = dvb.route("33000028", "33000016") + results = client.route("33000028", "33000016") assert isinstance(results, list) path = results[0].legs[0].path @@ -61,17 +61,16 @@ def test_parses_path(self, mocked_responses: object) -> None: assert len(path) == 2 assert abs(path[0].lat - 51.0) < 0.1 - def test_resolves_stop_names(self, mocked_responses: object) -> None: - # Two pointfinder calls (origin + dest), then trips + def test_resolves_stop_names(self, mocked_responses: object, client: Client) -> None: mock_get(mocked_responses, "tr/pointfinder", fixture="pointfinder.json") # type: ignore[arg-type] mock_get(mocked_responses, "tr/pointfinder", fixture="pointfinder.json") # type: ignore[arg-type] mock_post(mocked_responses, "tr/trips", fixture="trips.json") # type: ignore[arg-type] - results = dvb.route("Helmholtzstraße", "Postplatz") + results = client.route("Helmholtzstraße", "Postplatz") assert isinstance(results, list) assert len(results) == 1 - def test_raw_returns_dict(self, mocked_responses: object) -> None: + def test_raw_returns_dict(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "tr/trips", fixture="trips.json") # type: ignore[arg-type] - result = dvb.route("33000028", "33000016", raw=True) + result = client.route("33000028", "33000016", raw=True) assert isinstance(result, dict) assert "Routes" in result diff --git a/tests/test_route_changes.py b/tests/test_route_changes.py index c53faf7..865eba3 100644 --- a/tests/test_route_changes.py +++ b/tests/test_route_changes.py @@ -1,15 +1,15 @@ from __future__ import annotations -import dvb +from dvb import Client from dvb.models import RouteChange from .conftest import mock_post class TestRouteChanges: - def test_parses_changes(self, mocked_responses: object) -> None: + def test_parses_changes(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "rc", fixture="route_changes.json") # type: ignore[arg-type] - results = dvb.route_changes() + results = client.route_changes() assert isinstance(results, list) assert len(results) == 2 @@ -21,9 +21,9 @@ def test_parses_changes(self, mocked_responses: object) -> None: assert "

" in change.description assert change.lines == ["428296"] - def test_validity_periods(self, mocked_responses: object) -> None: + def test_validity_periods(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "rc", fixture="route_changes.json") # type: ignore[arg-type] - results = dvb.route_changes() + results = client.route_changes() assert isinstance(results, list) change = results[0] @@ -32,14 +32,14 @@ def test_validity_periods(self, mocked_responses: object) -> None: assert vp.begin.year == 2017 assert vp.end.year == 2017 - def test_multiple_line_ids(self, mocked_responses: object) -> None: + def test_multiple_line_ids(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "rc", fixture="route_changes.json") # type: ignore[arg-type] - results = dvb.route_changes() + results = client.route_changes() assert isinstance(results, list) assert results[1].lines == ["428300", "428301"] - def test_raw_returns_dict(self, mocked_responses: object) -> None: + def test_raw_returns_dict(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "rc", fixture="route_changes.json") # type: ignore[arg-type] - result = dvb.route_changes(raw=True) + result = client.route_changes(raw=True) assert isinstance(result, dict) assert "Changes" in result diff --git a/tests/test_trip_details.py b/tests/test_trip_details.py index d38e810..5f061de 100644 --- a/tests/test_trip_details.py +++ b/tests/test_trip_details.py @@ -2,16 +2,16 @@ from datetime import datetime, timezone -import dvb +from dvb import Client from dvb.models import RegularStop from .conftest import mock_post class TestTripDetails: - def test_parses_stops(self, mocked_responses: object) -> None: + def test_parses_stops(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm/trip", fixture="trip_details.json") # type: ignore[arg-type] - results = dvb.trip_details( + results = client.trip_details( trip_id="71313709", time=datetime(2017, 12, 6, 13, 24, 41, tzinfo=timezone.utc), stop_id="33000077", @@ -27,9 +27,9 @@ def test_parses_stops(self, mocked_responses: object) -> None: assert stop.platform is not None assert stop.platform.name == "2" - def test_stop_with_coords(self, mocked_responses: object) -> None: + def test_stop_with_coords(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm/trip", fixture="trip_details.json") # type: ignore[arg-type] - results = dvb.trip_details( + results = client.trip_details( trip_id="71313709", time=datetime(2017, 12, 6, 13, 24, 41, tzinfo=timezone.utc), stop_id="33000077", @@ -39,9 +39,9 @@ def test_stop_with_coords(self, mocked_responses: object) -> None: assert stop.coords is not None assert abs(stop.coords.lat - 51.0) < 0.1 - def test_stop_without_coords(self, mocked_responses: object) -> None: + def test_stop_without_coords(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm/trip", fixture="trip_details.json") # type: ignore[arg-type] - results = dvb.trip_details( + results = client.trip_details( trip_id="71313709", time=datetime(2017, 12, 6, 13, 24, 41, tzinfo=timezone.utc), stop_id="33000077", @@ -51,9 +51,9 @@ def test_stop_without_coords(self, mocked_responses: object) -> None: stop = results[1] assert stop.coords is None - def test_time_parsing(self, mocked_responses: object) -> None: + def test_time_parsing(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm/trip", fixture="trip_details.json") # type: ignore[arg-type] - results = dvb.trip_details( + results = client.trip_details( trip_id="71313709", time=datetime(2017, 12, 6, 13, 24, 41, tzinfo=timezone.utc), stop_id="33000077", @@ -64,9 +64,9 @@ def test_time_parsing(self, mocked_responses: object) -> None: assert stop.arrival.year == 2017 assert stop.arrival_real_time is not None - def test_stop_without_realtime(self, mocked_responses: object) -> None: + def test_stop_without_realtime(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm/trip", fixture="trip_details.json") # type: ignore[arg-type] - results = dvb.trip_details( + results = client.trip_details( trip_id="71313709", time=datetime(2017, 12, 6, 13, 24, 41, tzinfo=timezone.utc), stop_id="33000077", @@ -76,9 +76,9 @@ def test_stop_without_realtime(self, mocked_responses: object) -> None: stop = results[2] assert stop.arrival_real_time is None - def test_occupancy(self, mocked_responses: object) -> None: + def test_occupancy(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm/trip", fixture="trip_details.json") # type: ignore[arg-type] - results = dvb.trip_details( + results = client.trip_details( trip_id="71313709", time=datetime(2017, 12, 6, 13, 24, 41, tzinfo=timezone.utc), stop_id="33000077", @@ -87,9 +87,9 @@ def test_occupancy(self, mocked_responses: object) -> None: assert results[0].occupancy == "Unknown" assert results[1].occupancy == "ManySeats" - def test_raw_returns_dict(self, mocked_responses: object) -> None: + def test_raw_returns_dict(self, mocked_responses: object, client: Client) -> None: mock_post(mocked_responses, "dm/trip", fixture="trip_details.json") # type: ignore[arg-type] - result = dvb.trip_details( + result = client.trip_details( trip_id="71313709", time=datetime(2017, 12, 6, 13, 24, 41, tzinfo=timezone.utc), stop_id="33000077",