Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-depth: 0 # hatch-vcs derives the release version from git history and tags.

- name: Set up uv
uses: astral-sh/setup-uv@v10.3.0
Expand Down
5 changes: 1 addition & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,4 @@ jobs:
enable-cache: true

- name: Run tests
run: uv run --locked coverage run -m pytest

- name: Enforce coverage threshold
run: uv run --locked coverage report
run: uv run --locked pytest --cov=pyrail --cov-report=term-missing:skip-covered
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ uv run pytest
CI also enforces a coverage threshold: coverage over `pyrail/`, statements and branches together, must stay at or above 92 percent. Run it locally with:

```bash
uv run coverage run -m pytest && uv run coverage report
uv run pytest --cov=pyrail --cov-report=term-missing:skip-covered
```

To run the live suite, which needs network access:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Changelog = "https://github.com/tjorim/pyrail/releases"
test = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"coverage[toml]>=7.16.2",
"pytest-cov>=7.1.0",
]
dev = [
"ruff>=0.16.10",
Expand Down
112 changes: 35 additions & 77 deletions pyrail/irail.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

_API_BASE_URL = "https://api.irail.be/v1"

# Retry transient failures with exponential backoff, capped at the maximum delay.
_RETRY_BASE_DELAY = 1.0
_RETRY_MAX_DELAY = 10.0

Expand All @@ -50,6 +51,7 @@

_LANGUAGES: frozenset[str] = frozenset({"en", "nl", "fr", "de"})

# The API silently substitutes defaults for unknown values, so validate them locally.
_ALLOWED_VALUES: Mapping[str, frozenset[str]] = MappingProxyType(
{
"arrdep": frozenset({"departure", "arrival"}),
Expand All @@ -59,28 +61,6 @@
)


type _EndpointSpec = Mapping[str, tuple[str, ...]]


def _frozen_spec(spec: dict[str, tuple[str, ...]]) -> _EndpointSpec:
"""Freeze one endpoint spec, so the endpoint table cannot be mutated at any level."""
return MappingProxyType(spec)


_ENDPOINTS: Mapping[str, _EndpointSpec] = MappingProxyType(
{
"stations": _frozen_spec({}),
"liveboard": _frozen_spec({"xor": ("station", "id"), "optional": ("date", "time", "arrdep", "alerts")}),
"connections": _frozen_spec(
{"required": ("from", "to"), "optional": ("date", "time", "timesel", "typeOfTransport")}
),
"vehicle": _frozen_spec({"required": ("id",), "optional": ("date", "alerts")}),
"composition": _frozen_spec({"required": ("id",), "optional": ("data",)}),
"disturbances": _frozen_spec({"optional": ("lineBreakCharacter",)}),
}
)


def _describe(error: BaseException) -> str:
"""Describe an exception for log and error messages, including its type when it has no message."""
return f"{type(error).__name__}: {error}" if str(error) else type(error).__name__
Expand Down Expand Up @@ -389,51 +369,6 @@ def _validate_values(self, params: dict[str, Any]) -> None:
msg = f"Invalid {name}: expected one of {sorted(allowed)}, got: {params[name]}"
raise ValueError(msg)

def _validate_params(self, method: str, params: dict[str, Any] | None = None) -> None:
"""Validate parameters for a specific iRail API endpoint based on predefined requirements.

Checks that the method is a known endpoint, that all required parameters are present,
that the XOR parameter logic holds, that no unexpected parameters are given, and that
date, time and fixed-value parameters hold accepted values.

Args:
method (str): The API endpoint method to validate parameters for.
params (dict[str, Any], optional): Dictionary of parameters to validate. Defaults to None.

Raises:
ValueError: If any of the checks fails.

"""
if method not in _ENDPOINTS:
msg = f"Unknown API endpoint: {method}"
raise ValueError(msg)

endpoint = _ENDPOINTS[method]
required = endpoint.get("required", ())
xor = endpoint.get("xor", ())
optional = endpoint.get("optional", ())

params = params or {}

self._validate_values(params)

for param in required:
if param not in params or params[param] is None:
msg = f"Missing required parameter: {param} for endpoint: {method}"
raise ValueError(msg)

if xor:
xor_values = [params.get(param) is not None for param in xor]
if sum(xor_values) != 1:
msg = f"Exactly one of the XOR parameters {xor} must be provided for endpoint: {method}"
raise ValueError(msg)

all_params = required + xor + optional
for param in params:
if param not in all_params:
msg = f"Unexpected parameter: {param} for endpoint: {method}"
raise ValueError(msg)

async def _handle_success_response(
self, response: ClientResponse, method: str
) -> tuple[dict[str, Any], str | None]:
Expand Down Expand Up @@ -556,9 +491,10 @@ async def _do_request[T](
) -> T | None:
"""Send an asynchronous request to the specified iRail API endpoint.

This method handles API requests with rate limiting, parameter validation,
and ETag-based caching. It supports conditional requests and manages
various HTTP response scenarios. Network errors, timeouts and server errors (5xx)
This method handles API requests with rate limiting and ETag-based caching.
Endpoint methods validate their parameters before calling this helper.
It supports conditional requests and manages various HTTP response scenarios.
Network errors, timeouts and server errors (5xx)
are retried up to ``max_retries`` times with exponential backoff. The response
is deserialized with ``from_dict``, and its ETag is cached only after the
response is fully accepted, so a rejected response never leaves a cache entry.
Expand All @@ -574,7 +510,6 @@ async def _do_request[T](
not modified (304).

Raises:
ValueError: If the method is not a known endpoint or the parameters are invalid.
RuntimeError: If the active session has been closed.
RateLimitError: If the API responds with HTTP 429. The exception's ``retry_after`` holds the
Retry-After delay in seconds, if given. The request is not retried.
Expand All @@ -597,7 +532,6 @@ async def _do_request[T](

"""
logger.debug("Starting request to endpoint: %s", method)
self._validate_params(method, args or {})

url: str = f"{_API_BASE_URL}/{method}/"
params = {"format": "json", "lang": self.lang}
Expand All @@ -609,6 +543,7 @@ async def _do_request[T](
max_attempts = self.max_retries + 1
attempt = 1
while True:
# Retries must consume a token too, or failures could exceed the API limits.
async with self._lock:
await self._handle_rate_limit()

Expand Down Expand Up @@ -652,7 +587,7 @@ def _parse_response[T](self, method: str, data: dict[str, Any], from_dict: Calla
return from_dict(data)
except (LookupError, ValueError, OverflowError) as e:
msg = f"Response from {method} does not match the expected model: {_describe(e)}"
raise InvalidResponseError(msg) from e
raise InvalidResponseError(msg, status=200) from e

async def get_stations(self) -> StationsApiResponse | None:
"""Retrieve a list of all train stations from the iRail API.
Expand Down Expand Up @@ -730,10 +665,16 @@ async def get_liveboard(
"arrdep": arrdep,
"alerts": "true" if alerts else "false",
}
params = {k: v for k, v in extra_params.items() if v is not None}
self._validate_values(params)
if (station is None) == (station_id is None):
msg = "Provide exactly one of station or station_id"
raise ValueError(msg)

return await self._do_request(
"liveboard",
LiveboardApiResponse.from_dict,
{k: v for k, v in extra_params.items() if v is not None},
params,
)

async def get_connections(
Expand Down Expand Up @@ -783,10 +724,16 @@ async def get_connections(
"timesel": timesel,
"typeOfTransport": type_of_transport,
}
params = {k: v for k, v in extra_params.items() if v is not None}
self._validate_values(params)
if from_station is None or to_station is None:
msg = "Both from_station and to_station must be provided"
raise ValueError(msg)

return await self._do_request(
"connections",
ConnectionsApiResponse.from_dict,
{k: v for k, v in extra_params.items() if v is not None},
params,
)

async def get_vehicle(
Expand Down Expand Up @@ -822,10 +769,16 @@ async def get_vehicle(

"""
extra_params: dict[str, Any] = {"id": vehicle_id, "date": date, "alerts": "true" if alerts else "false"}
params = {k: v for k, v in extra_params.items() if v is not None}
self._validate_values(params)
if vehicle_id is None:
msg = "vehicle_id must be provided"
raise ValueError(msg)

return await self._do_request(
"vehicle",
VehicleApiResponse.from_dict,
{k: v for k, v in extra_params.items() if v is not None},
params,
)

async def get_composition(
Expand Down Expand Up @@ -857,10 +810,15 @@ async def get_composition(

"""
extra_params: dict[str, str | None] = {"id": vehicle_id, "data": data}
params = {k: v for k, v in extra_params.items() if v is not None}
if vehicle_id is None:
msg = "vehicle_id must be provided"
raise ValueError(msg)

return await self._do_request(
"composition",
CompositionApiResponse.from_dict,
{k: v for k, v in extra_params.items() if v is not None},
params,
)

async def get_disturbances(self, line_break_character: str | None = None) -> DisturbancesApiResponse | None:
Expand Down
2 changes: 1 addition & 1 deletion pyrail/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ class VehicleStop(DataClassORJSONMixin):
left: bool = field(metadata=field_options(deserialize=_str_to_bool))
arrived: bool = field(metadata=field_options(deserialize=_str_to_bool))
is_extra_stop: bool = field(metadata=field_options(alias="isExtraStop", deserialize=_str_to_bool))
occupancy: Occupancy | None = field(default=None)
occupancy: Occupancy | None = field(default=None) # Not present on the last stop.
departure_connection: str | None = field(default=None, metadata=field_options(alias="departureConnection"))


Expand Down
2 changes: 2 additions & 0 deletions tests/live/live_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from pyrail.exceptions import InvalidRequestError, InvalidResponseError, NotFoundError, PyRailError
from pyrail.irail import IRail

# The fixture disables client retries, so this is the only retry layer; client
# rate limiting still applies to every attempt.
ATTEMPTS = 3
BACKOFF_SECONDS = (5, 15)
REQUEST_TIMEOUT = ClientTimeout(total=30)
Expand Down
Loading
Loading