Skip to content

Commit

Permalink
feat: update unit tests to use StationDetails and add bad_disturbance…
Browse files Browse the repository at this point in the history
…s.json example
  • Loading branch information
tjorim committed Jan 7, 2025
1 parent 74253f8 commit 47dd224
Show file tree
Hide file tree
Showing 5 changed files with 475 additions and 93 deletions.
131 changes: 131 additions & 0 deletions examples/bad_composition.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
{
"version": "1.1",
"timestamp": "1581856899",
"composition": {
"segments": {
"number": "1",
"segment": [
{
"id": "0",
"origin": {
"locationX": "4.071825",
"locationY": "50.891925",
"id": "BE.NMBS.008895802",
"name": "Denderleeuw",
"@id": "http://irail.be/stations/NMBS/008895802",
"standardname": "Denderleeuw"
},
"destination": {
"locationX": "4.071825",
"locationY": "50.891925",
"id": "BE.NMBS.008895802",
"name": "Denderleeuw",
"@id": "http://irail.be/stations/NMBS/008895802",
"standardname": "Denderleeuw"
},
"composition": {
"source": "Itris",
"units": {
"number": "3",
"unit": [
{
"id": "0",
"materialType": {
"parent_type": "AM08M",
"sub_type": "c",
"orientation": "LEFT"
},
"hasToilets": "1",
"hasTables": "1",
"hasSecondClassOutlets": "1",
"hasFirstClassOutlets": "1",
"hasHeating": "1",
"hasAirco": "1",
"materialNumber": "8112",
"tractionType": "AM/MR",
"canPassToNextUnit": "0",
"standingPlacesSecondClass": "27",
"standingPlacesFirstClass": "9",
"seatsCoupeSecondClass": "0",
"seatsCoupeFirstClass": "0",
"seatsSecondClass": "76",
"seatsFirstClass": "16",
"lengthInMeter": "27",
"hasSemiAutomaticInteriorDoors": "1",
"hasLuggageSection": "0",
"materialSubTypeName": "AM08M_c",
"tractionPosition": "1",
"hasPrmSection": "1",
"hasPriorityPlaces": "1",
"hasBikeSection": "1"
},
{
"id": "1",
"materialType": {
"parent_type": "AM08M",
"sub_type": "b",
"orientation": "LEFT"
},
"hasToilets": "0",
"hasTables": "1",
"hasSecondClassOutlets": "1",
"hasFirstClassOutlets": "1",
"hasHeating": "1",
"hasAirco": "1",
"materialNumber": "8112",
"tractionType": "AM/MR",
"canPassToNextUnit": "0",
"standingPlacesSecondClass": "40",
"standingPlacesFirstClass": "0",
"seatsCoupeSecondClass": "0",
"seatsCoupeFirstClass": "0",
"seatsSecondClass": "104",
"seatsFirstClass": "0",
"lengthInMeter": "27",
"hasSemiAutomaticInteriorDoors": "1",
"hasLuggageSection": "0",
"materialSubTypeName": "AM08M_b",
"tractionPosition": "1",
"hasPrmSection": "0",
"hasPriorityPlaces": "1",
"hasBikeSection": "0"
},
{
"id": "2",
"materialType": {
"parent_type": "AM08M",
"sub_type": "a",
"orientation": "RIGHT"
},
"hasToilets": "0",
"hasTables": "1",
"hasSecondClassOutlets": "1",
"hasFirstClassOutlets": "1",
"hasHeating": "1",
"hasAirco": "1",
"materialNumber": "8112",
"tractionType": "AM/MR",
"canPassToNextUnit": "0",
"standingPlacesSecondClass": "37",
"standingPlacesFirstClass": "9",
"seatsCoupeSecondClass": "0",
"seatsCoupeFirstClass": "0",
"seatsSecondClass": "68",
"seatsFirstClass": "16",
"lengthInMeter": "27",
"hasSemiAutomaticInteriorDoors": "1",
"hasLuggageSection": "0",
"materialSubTypeName": "AM08M_a",
"tractionPosition": "1",
"hasPrmSection": "0",
"hasPriorityPlaces": "1",
"hasBikeSection": "0"
}
]
}
}
}
]
}
}
}
48 changes: 48 additions & 0 deletions examples/bad_disturbances.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"version": "1.1",
"timestamp": "1581853952",
"disturbance": [
{
"id": "0",
"title": "Brux.-Midi/Brus.-Zuid - Amsterdam CS (NL): Incident on the Dutch rail network.",
"description": "Between Brux.-Midi/Brus.-Zuid and Amsterdam CS (NL): Delays and cancellations are possible. Between Rotterdam CS (NL) and Amsterdam CS (NL): Disrupted train traffic. Indefinite duration of the failure. Listen to the announcements, consult the automatic departure boards or plan your trip via the SNCB app or sncb.be for more information.",
"link": "http://www.belgianrail.be/jp/nmbs-realtime/help.exe/en?tpl=showmap_external&tplParamHimMsgInfoGroup=trouble&messageID=41188&channelFilter=custom2,livemap,rss_line_10,twitter,custom1,timetable&",
"type": "disturbance",
"timestamp": "1581853724"
},
{
"id": "1",
"title": "Soignies / Zinnik: Failure of a level crossing.",
"description": "Delays between 5 and 15 minutes are possible in both directions. Indefinite duration of the failure. Listen to the announcements, consult the automatic departure boards or plan your trip via the SNCB app or sncb.be for more information.",
"link": "http://www.belgianrail.be/jp/nmbs-realtime/help.exe/en?tpl=showmap_external&tplParamHimMsgInfoGroup=trouble&messageID=41187&channelFilter=custom2,livemap,rss_line_10,twitter,custom1,timetable&",
"type": "disturbance",
"timestamp": "1581849358"
},
{
"id": "2",
"title": "Storm warning.",
"description": "On entire network, from saturday 15/02 evening to sunday 16/02 : Delays and cancellations are possible. Listen to the announcements, consult the automatic departure boards or plan your trip via the SNCB app or sncb.be for more information.",
"link": "http://www.belgianrail.be/jp/nmbs-realtime/help.exe/en?tpl=showmap_external&tplParamHimMsgInfoGroup=trouble&messageID=41174&channelFilter=timetable,custom1,twitter,rss_line_10,livemap,custom2&",
"type": "disturbance",
"timestamp": "1581767543"
},
{
"id": "3",
"title": "Brux.-Nord/Brus.-Noord - Schaerbeek / Schaarbeek",
"description": "We are conducting work for you between Brux.-Nord/Brus.-Noord and Schaerbeek / Schaarbeek. Detailed information only available in French (FR) and in Dutch (NL).",
"link": "http://www.belgianrail.be/jp/nmbs-realtime/help.exe/en?tpl=showmap_external&tplParamHimMsgInfoGroup=works&messageID=41159&channelFilter=rss_line_90&",
"type": "planned",
"timestamp": "1581691640",
"attachment": "http://www.belgianrail.be/jp/download/brail_him/1581691545283_NL-03003S.pdf"
},
{
"id": "4",
"title": "Ostende / Oostende - Anvers-Central / Antwerpen-Centraal",
"description": "We are conducting work for you between Ostende / Oostende and Anvers-Central / Antwerpen-Centraal. Detailed information only available in French (FR) and in Dutch (NL).",
"link": "http://www.belgianrail.be/jp/nmbs-realtime/help.exe/en?tpl=showmap_external&tplParamHimMsgInfoGroup=works&messageID=40825&channelFilter=timetable,rss_line_90,custom2&",
"type": "planned",
"timestamp": "1581691528",
"attachment": "http://www.belgianrail.be/jp/download/brail_him/1580998838767_NL-02045S.pdf"
}
]
}
51 changes: 20 additions & 31 deletions pyrail/irail.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Module providing the iRail class for interacting with the iRail API."""

import asyncio
from asyncio import Lock
from datetime import datetime
Expand All @@ -9,10 +10,9 @@

from aiohttp import ClientError, ClientResponse, ClientSession

from pyrail.models import Station, StationsApiResponse
from pyrail.models import StationDetails, StationsApiResponse

logging.basicConfig(level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger: logging.Logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -151,11 +151,9 @@ def _add_etag_header(self, method: str) -> Dict[str, str]:
if a cached value exists.
"""
headers: Dict[str, str] = {
"User-Agent": "pyRail (https://github.com/tjorim/pyrail; tielemans.jorim@gmail.com)"}
headers: Dict[str, str] = {"User-Agent": "pyRail (https://github.com/tjorim/pyrail; tielemans.jorim@gmail.com)"}
if method in self.etag_cache:
logger.debug("Adding If-None-Match header with value: %s",
self.etag_cache[method])
logger.debug("Adding If-None-Match header with value: %s", self.etag_cache[method])
headers["If-None-Match"] = self.etag_cache[method]
return headers

Expand All @@ -176,8 +174,7 @@ def _validate_date(self, date: str | None) -> bool:
datetime.strptime(date, "%d%m%y")
return True
except ValueError:
logger.error(
"Invalid date format. Expected DDMMYY (e.g., 150923 for September 15, 2023), got: %s", date)
logger.error("Invalid date format. Expected DDMMYY (e.g., 150923 for September 15, 2023), got: %s", date)
return False

def _validate_time(self, time: str | None) -> bool:
Expand All @@ -197,8 +194,7 @@ def _validate_time(self, time: str | None) -> bool:
datetime.strptime(time, "%H%M")
return True
except ValueError:
logger.error(
"Invalid time format. Expected HHMM (e.g., 1430 for 2:30 PM), got: %s", time)
logger.error("Invalid time format. Expected HHMM (e.g., 1430 for 2:30 PM), got: %s", time)
return False

def _validate_params(self, method: str, params: Dict[str, Any] | None = None) -> bool:
Expand Down Expand Up @@ -245,24 +241,21 @@ def _validate_params(self, method: str, params: Dict[str, Any] | None = None) ->
# Ensure all required parameters are present
for param in required:
if param not in params or params[param] is None:
logger.error(
"Missing required parameter: %s for endpoint: %s", param, method)
logger.error("Missing required parameter: %s for endpoint: %s", param, method)
return False

# Check XOR logic (only one of XOR parameters can be set)
if xor:
xor_values = [params.get(param) is not None for param in xor]
if sum(xor_values) != 1:
logger.error(
"Exactly one of the XOR parameters %s must be provided for endpoint: %s", xor, method)
logger.error("Exactly one of the XOR parameters %s must be provided for endpoint: %s", xor, method)
return False

# Ensure no unexpected parameters are included
all_params = required + xor + optional
for param in params.keys():
if param not in all_params:
logger.error(
"Unexpected parameter: %s for endpoint: %s", param, method)
logger.error("Unexpected parameter: %s for endpoint: %s", param, method)
return False

return True
Expand All @@ -280,12 +273,13 @@ async def _handle_success_response(self, response: ClientResponse, method: str)
logger.error("Failed to parse JSON response")
return None

async def _handle_response(self, response: ClientResponse, method: str, args: Dict[str, Any] | None = None) -> Dict[str, Any] | None:
async def _handle_response(
self, response: ClientResponse, method: str, args: Dict[str, Any] | None = None
) -> Dict[str, Any] | None:
"""Handle the API response based on status code."""
if response.status == 429:
retry_after: int = int(response.headers.get("Retry-After", 1))
logger.warning(
"Rate limited, retrying after %d seconds", retry_after)
logger.warning("Rate limited, retrying after %d seconds", retry_after)
await asyncio.sleep(retry_after)
return await self._do_request(method, args)
elif response.status == 400:
Expand All @@ -297,8 +291,7 @@ async def _handle_response(self, response: ClientResponse, method: str, args: Di
elif response.status == 200:
return await self._handle_success_response(response, method)
elif response.status == 304:
logger.info(
"Data not modified, using cached data for method %s", method)
logger.info("Data not modified, using cached data for method %s", method)
return None
else:
logger.error("Request failed with status code: %s, response: %s", response.status, await response.text())
Expand Down Expand Up @@ -332,12 +325,10 @@ async def _do_request(self, method: str, args: Dict[str, Any] | None = None) ->
"""
logger.info("Starting request to endpoint: %s", method)
if self.session is None:
logger.error(
"Session not initialized. Use 'async with' context manager to initialize the client.")
logger.error("Session not initialized. Use 'async with' context manager to initialize the client.")
return None
if not self._validate_params(method, args or {}):
logger.error(
"Validation failed for method: %s with args: %s", method, args)
logger.error("Validation failed for method: %s with args: %s", method, args)
return None
async with self.lock:
await self._handle_rate_limit()
Expand All @@ -357,7 +348,7 @@ async def _do_request(self, method: str, args: Dict[str, Any] | None = None) ->
logger.error("Request failed due to an exception: %s", e)
return None

async def get_stations(self) -> List[Station] | None:
async def get_stations(self) -> List[StationDetails] | None:
"""Retrieve a list of all train stations from the iRail API.
This method fetches the complete list of available train stations without any additional filtering parameters.
Expand All @@ -376,8 +367,7 @@ async def get_stations(self) -> List[Station] | None:
stations_dict = await self._do_request("stations")
if stations_dict is None:
return None
stations_response: StationsApiResponse = StationsApiResponse.from_dict(
stations_dict)
stations_response: StationsApiResponse = StationsApiResponse.from_dict(stations_dict)
return stations_response.stations

async def get_liveboard(
Expand Down Expand Up @@ -477,8 +467,7 @@ async def get_vehicle(self, id: str, date: str | None = None, alerts: bool = Fal
vehicle_info = await client.get_vehicle("BE.NMBS.IC1832")
"""
extra_params: Dict[str, Any] = {
"id": id, "date": date, "alerts": "true" if alerts else "false"}
extra_params: Dict[str, Any] = {"id": id, "date": date, "alerts": "true" if alerts else "false"}
return await self._do_request("vehicle", {k: v for k, v in extra_params.items() if v is not None})

async def get_composition(self, id: str, data: str | None = None) -> Dict[str, Any] | None:
Expand Down
Loading

0 comments on commit 47dd224

Please sign in to comment.