"""Module defining data models for the pyrail application.""" from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import Any from mashumaro import field_options from mashumaro.mixins.orjson import DataClassORJSONMixin def _timestamp_to_datetime(timestamp: str) -> datetime: """Convert an epoch timestamp to a datetime object.""" return datetime.fromtimestamp(int(timestamp), timezone.utc) def _str_to_bool(strbool: str) -> bool: """Convert a string ("0" or "1") to a boolean.""" return strbool == "1" class DisturbanceType(Enum): """Enum for the type of disturbance, either 'disturbance' or 'planned'.""" DISTURBANCE = "disturbance" PLANNED = "planned" class OccupancyName(Enum): """Enum for the occupancy, either 'low', 'medium', 'high', or 'unknown'.""" LOW = "low" MEDIUM = "medium" HIGH = "high" UNKNOWN = "unknown" class Orientation(Enum): """Enum for the orientation of the material type of a train unit, either 'LEFT' or 'RIGHT'.""" LEFT = "LEFT" RIGHT = "RIGHT" @dataclass class ApiResponse(DataClassORJSONMixin): """Base class for API responses, including schema version and timestamp.""" version: str # Version of the response schema timestamp: datetime = field(metadata=field_options(deserialize=_timestamp_to_datetime)) # Timestamp of the response @dataclass class StationDetails(DataClassORJSONMixin): """Represents a single railway station with location and naming attributes.""" at_id: str = field(metadata=field_options(alias="@id")) # Corresponds to "@id" in the schema id: str # The (iRail) ID of the station name: str # Default name of the station longitude: float = field(metadata=field_options(alias="locationX")) # Longitude of the station latitude: float = field(metadata=field_options(alias="locationY")) # Latitude of the station standard_name: str = field(metadata=field_options(alias="standardname")) # Consistent name of the station @dataclass class StationsApiResponse(ApiResponse): """Holds a list of station objects returned by the 'stations' endpoint.""" stations: list[StationDetails] = field( metadata=field_options(alias="station"), default_factory=list ) # List of stations information @dataclass class VehicleInfo(DataClassORJSONMixin): """Represents information about a specific vehicle, including name and location.""" name: str # Name of the vehicle short_name: str = field(metadata=field_options(alias="shortname")) # Shortened name of the vehicle number: str # Number of the vehicle type: str # Type of vehicle (e.g., IC, EC) longitude: float = field(metadata=field_options(alias="locationX")) # Longitude of the vehicle latitude: float = field(metadata=field_options(alias="locationY")) # Latitude of the vehicle at_id: str = field(metadata=field_options(alias="@id")) # ID of the vehicle @dataclass class PlatformInfo(DataClassORJSONMixin): """Details about the platform, such as name and whether it is the normal one.""" name: str # Platform name normal: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether it is the normal platform @dataclass class Occupancy(DataClassORJSONMixin): """Represents occupancy details for a specific departure.""" at_id: str = field(metadata=field_options(alias="@id")) # Identifier for the occupancy level name: OccupancyName # Occupancy level (e.g., low, high) @dataclass class LiveboardDeparture(DataClassORJSONMixin): """Details of a single departure in the liveboard response.""" id: str # ID of the departure station: str # Station name station_info: StationDetails = field(metadata=field_options(alias="stationinfo")) # Detailed station info time: datetime = field(metadata=field_options(deserialize=_timestamp_to_datetime)) # Departure time (timestamp) delay: int # Delay in seconds canceled: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether the departure is canceled left: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether the train has left is_extra: bool = field( metadata=field_options(alias="isExtra", deserialize=_str_to_bool) ) # Whether the train is extra vehicle: str # Vehicle identifier vehicle_info: VehicleInfo = field(metadata=field_options(alias="vehicleinfo")) # Vehicle details platform: str # Platform name platform_info: PlatformInfo = field(metadata=field_options(alias="platforminfo")) # Detailed platform info occupancy: Occupancy # Occupancy level departure_connection: str = field(metadata=field_options(alias="departureConnection")) # Departure connection link @dataclass class LiveboardArrival(DataClassORJSONMixin): """Details of a single arrival in the liveboard response.""" id: str # ID of the arrival station: str # Station name station_info: StationDetails = field(metadata=field_options(alias="stationinfo")) # Detailed station info time: datetime = field(metadata=field_options(deserialize=_timestamp_to_datetime)) # Arrival time (timestamp) delay: int # Delay in seconds canceled: bool # Whether the arrival is canceled arrived: bool # Whether the train has arrived is_extra: bool = field(metadata=field_options(alias="isExtra")) # Whether the train is extra vehicle: str # Vehicle identifier vehicle_info: VehicleInfo = field(metadata=field_options(alias="vehicleinfo")) # Vehicle details platform: str # Platform name platform_info: PlatformInfo = field(metadata=field_options(alias="platforminfo")) # Detailed platform info departure_connection: str = field(metadata=field_options(alias="departureConnection")) # Departure connection link @dataclass class LiveboardApiResponse(ApiResponse): """Represents a liveboard response containing station details and departure/arrival information.""" station: str # Name of the station station_info: StationDetails = field(metadata=field_options(alias="stationinfo")) # Detailed station info departures: list[LiveboardDeparture] | None = field(default=None) # Departures information arrivals: list[LiveboardArrival] | None = field(default=None) # Arrivals information @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Pre-deserialization hook to safely flatten departures and arrivals.""" # Safely flatten departures if "departures" in d and d["departures"] is not None: d["departures"] = d["departures"].get("departure", []) else: d["departures"] = None # Set to None if missing or null # Safely flatten arrivals if "arrivals" in d and d["arrivals"] is not None: d["arrivals"] = d["arrivals"].get("arrival", []) else: d["arrivals"] = None # Set to None if missing or null return d @dataclass class ConnectionStop(DataClassORJSONMixin): """Represents a single stop in a journey for connections.""" id: str # Stop ID station: str # Station name station_info: StationDetails = field(metadata=field_options(alias="stationinfo")) # Detailed station info scheduled_arrival_time: datetime = field( metadata=field_options(alias="scheduledArrivalTime", deserialize=_timestamp_to_datetime) ) # Scheduled arrival time arrival_canceled: bool = field( metadata=field_options(alias="arrivalCanceled", deserialize=_str_to_bool) ) # Arrival cancellation status arrived: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Arrival status scheduled_departure_time: datetime = field( metadata=field_options(alias="scheduledDepartureTime", deserialize=_timestamp_to_datetime) ) # Scheduled departure time arrival_delay: int = field(metadata=field_options(alias="arrivalDelay")) # Arrival delay departure_delay: int = field(metadata=field_options(alias="departureDelay")) # Departure delay departure_canceled: bool = field( metadata=field_options(alias="departureCanceled", deserialize=_str_to_bool) ) # Departure cancellation status left: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Departure status is_extra_stop: bool = field( metadata=field_options(alias="isExtraStop", deserialize=_str_to_bool) ) # Whether the stop is an extra one platform: str # Platform name platform_info: PlatformInfo = field(metadata=field_options(alias="platforminfo")) # Detailed platform info @dataclass class Direction(DataClassORJSONMixin): """Represents the direction of a train connection.""" name: str # Direction name @dataclass class ConnectionDeparture(DataClassORJSONMixin): """Details of a single departure in the connections response.""" delay: int # Delay in seconds station: str # Station name station_info: StationDetails = field(metadata=field_options(alias="stationinfo")) # Detailed station info time: datetime = field(metadata=field_options(deserialize=_timestamp_to_datetime)) # Departure time (timestamp) vehicle: str # Vehicle identifier vehicle_info: VehicleInfo = field(metadata=field_options(alias="vehicleinfo")) # Vehicle details platform: str # Platform name platform_info: PlatformInfo = field(metadata=field_options(alias="platforminfo")) # Detailed platform info canceled: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether the departure is canceled departure_connection: str = field(metadata=field_options(alias="departureConnection")) # Departure connection link direction: Direction # Direction of the connection left: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether the train has left walking: bool = field( metadata=field_options(deserialize=_str_to_bool) ) # Indicates if the connection requires walking occupancy: Occupancy # Occupancy level stops: list[ConnectionStop] = field(default_factory=list) # List of stop along the journey @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Extract 'stop' list from 'stops' before deserialization.""" d["stops"] = d["stops"]["stop"] return d @dataclass class ConnectionArrival(DataClassORJSONMixin): """Details of a single arrival, including timing, delay, and vehicle information.""" delay: int # Delay in seconds station: str # Station name station_info: StationDetails = field(metadata=field_options(alias="stationinfo")) # Detailed station info time: datetime = field(metadata=field_options(deserialize=_timestamp_to_datetime)) # Arrival time (timestamp) vehicle: str # Vehicle identifier vehicle_info: VehicleInfo = field(metadata=field_options(alias="vehicleinfo")) # Vehicle details platform: str # Platform name platform_info: PlatformInfo = field(metadata=field_options(alias="platforminfo")) # Detailed platform info canceled: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether the arrival is canceled direction: Direction # Direction of the connection arrived: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether the train has arrived walking: bool = field( metadata=field_options(deserialize=_str_to_bool) ) # Indicates if the connection requires walking departure_connection: str = field(metadata=field_options(alias="departureConnection")) # Departure connection link @dataclass class Via(DataClassORJSONMixin): """Represents a single via station in a train connection.""" id: str # Via ID arrival: ConnectionArrival departure: ConnectionDeparture timebetween: int # Time between arrival and departure (assuming in seconds) station: str # Station name station_info: StationDetails = field(metadata=field_options(alias="stationinfo")) # Detailed station info vehicle: str # Vehicle identifier vehicle_info: VehicleInfo = field(metadata=field_options(alias="vehicleinfo")) # Vehicle details @dataclass class Remark(DataClassORJSONMixin): """Represents a single remark for a train connection, including type and content.""" id: str # Remark ID # Unsure about the content of a remark, so using a generic type for now content: str # Remark content @dataclass class Alert(DataClassORJSONMixin): """Represents a single alert for a train connection, including type and content.""" id: str # Alert ID header: str # Alert header description: str # Alert description lead: str # Alert lead start_time: datetime = field( metadata=field_options(alias="startTime", deserialize=_timestamp_to_datetime) ) # Start time of the alert end_time: datetime = field( metadata=field_options(alias="endTime", deserialize=_timestamp_to_datetime) ) # End time of the alert link: str | None = field(default=None) # Link to more information @dataclass class ConnectionDetails(DataClassORJSONMixin): """Details of a single connection, including departure and arrival information.""" id: str # Connection ID departure: ConnectionDeparture # Departure details arrival: ConnectionArrival # Arrival details duration: int # Duration of the connection in minutes remarks: list[Remark] = field(default_factory=list) # List of remarks for the connection alerts: list[Alert] = field(default_factory=list) # List of alerts for the connection vias: list[Via] | None = field(default=None) # List of via details @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Flatten the structure of the 'connections' response by extracting lists of remarks, alerts, and optionally flatten vias before deserialization.""" # Extract 'remark' and 'alert' list from 'remarks' and 'alerts' before deserialization. d["remarks"] = d["remarks"]["remark"] d["alerts"] = d["alerts"]["alert"] # Safely flatten vias if "vias" in d and d["vias"] is not None: d["vias"] = d["vias"].get("via", []) else: d["vias"] = None # Set to None if missing or null return d @dataclass class ConnectionsApiResponse(ApiResponse): """Holds a list of connections returned by the connections endpoint.""" connections: list[ConnectionDetails] = field( metadata=field_options(alias="connection"), default_factory=list ) # List of connections @dataclass class VehicleStop(DataClassORJSONMixin): """Represents a single stop in a journey for vehicles.""" id: str # Stop ID station: str # Station name station_info: StationDetails = field(metadata=field_options(alias="stationinfo")) # Detailed station info time: datetime = field( metadata=field_options(deserialize=_timestamp_to_datetime) ) # Scheduled stop time (timestamp) platform: str # Platform name platform_info: PlatformInfo = field(metadata=field_options(alias="platforminfo")) # Detailed platform info scheduled_departure_time: datetime = field( metadata=field_options(alias="scheduledDepartureTime", deserialize=_timestamp_to_datetime) ) # Scheduled departure time scheduled_arrival_time: datetime = field( metadata=field_options(alias="scheduledArrivalTime", deserialize=_timestamp_to_datetime) ) # Scheduled arrival time delay: int # Delay in minutes canceled: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether the stop is canceled departure_delay: int = field(metadata=field_options(alias="departureDelay")) # Departure delay departure_canceled: bool = field( metadata=field_options(alias="departureCanceled", deserialize=_str_to_bool) ) # Departure cancellation status arrival_delay: int = field(metadata=field_options(alias="arrivalDelay")) # Arrival delay arrival_canceled: bool = field( metadata=field_options(alias="arrivalCanceled", deserialize=_str_to_bool) ) # Arrival cancellation status left: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether the train has left arrived: bool = field(metadata=field_options(deserialize=_str_to_bool)) # Whether the train has arrived is_extra_stop: bool = field( metadata=field_options(alias="isExtraStop", deserialize=_str_to_bool) ) # Whether this is an extra stop occupancy: Occupancy | None = field(default=None) # Occupancy level, not present in last stop departure_connection: str | None = field( default=None, metadata=field_options(alias="departureConnection") ) # Departure connection link, not present in the last stop @dataclass class VehicleApiResponse(ApiResponse): """Provides detailed data about a particular vehicle, including a list of its stops.""" vehicle: str # Vehicle identifier vehicle_info: VehicleInfo = field(metadata=field_options(alias="vehicleinfo")) # Vehicle information stops: list[VehicleStop] = field(default_factory=list) # List of stop details @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Extract 'stop' list from 'stops' before deserialization.""" d["stops"] = d["stops"]["stop"] return d @dataclass class MaterialType(DataClassORJSONMixin): """Represents the material type of a train unit.""" parent_type: str # Parent material type sub_type: str # Sub material type orientation: Orientation # Orientation of the material type @dataclass class Unit(DataClassORJSONMixin): """Represents a single train unit, including its type and location.""" id: str # Unit ID material_type: MaterialType = field(metadata=field_options(alias="materialType")) # Material type of the unit has_toilets: bool = field( metadata=field_options(alias="hasToilets", deserialize=_str_to_bool) ) # Whether the unit has toilets has_second_class_outlets: bool = field( metadata=field_options(alias="hasSecondClassOutlets", deserialize=_str_to_bool) ) # Whether the unit has power outlets in second class has_first_class_outlets: bool = field( metadata=field_options(alias="hasFirstClassOutlets", deserialize=_str_to_bool) ) # Whether the unit has power outlets in first class has_heating: bool = field( metadata=field_options(alias="hasHeating", deserialize=_str_to_bool) ) # Whether the unit has heating has_airco: bool = field( metadata=field_options(alias="hasAirco", deserialize=_str_to_bool) ) # Whether the unit has air conditioning traction_type: str = field(metadata=field_options(alias="tractionType")) # Traction type of the unit can_pass_to_next_unit: bool = field( metadata=field_options(alias="canPassToNextUnit", deserialize=_str_to_bool) ) # Whether the unit can pass to the next seats_first_class: int = field(metadata=field_options(alias="seatsFirstClass")) # Number of seats in first class seats_coupe_first_class: int = field( metadata=field_options(alias="seatsCoupeFirstClass") ) # Number of seats in coupe in first class standing_places_first_class: int = field( metadata=field_options(alias="standingPlacesFirstClass") ) # Number of standing places in first class seats_second_class: int = field(metadata=field_options(alias="seatsSecondClass")) # Number of seats in second class seats_coupe_second_class: int = field( metadata=field_options(alias="seatsCoupeSecondClass") ) # Number of seats in coupe in second class standing_places_second_class: int = field( metadata=field_options(alias="standingPlacesSecondClass") ) # Number of standing places in second class length_in_meter: int = field(metadata=field_options(alias="lengthInMeter")) # Length of the unit in meters has_semi_automatic_interior_doors: bool = field( metadata=field_options(alias="hasSemiAutomaticInteriorDoors", deserialize=_str_to_bool) ) # Whether the unit has semi-automatic interior doors traction_position: int = field(metadata=field_options(alias="tractionPosition")) # Traction position of the unit has_prm_section: bool = field( metadata=field_options(alias="hasPrmSection", deserialize=_str_to_bool) ) # Whether the unit has a PRM section has_priority_places: bool = field( metadata=field_options(alias="hasPriorityPlaces", deserialize=_str_to_bool) ) # Whether the unit has priority places has_bike_section: bool = field( metadata=field_options(alias="hasBikeSection", deserialize=_str_to_bool) ) # Whether the unit has a bike section @dataclass class SegmentComposition(DataClassORJSONMixin): """Describes a collection of train units and related metadata.""" source: str # Source of the composition units: list[Unit] = field(default_factory=list) # List of units @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Extract 'unit' list from 'units' before deserialization.""" d["units"] = d["units"]["unit"] return d @dataclass class Segment(DataClassORJSONMixin): """Defines a single segment within a journey, including composition details.""" id: str # ID of the segment origin: StationDetails # Origin station information destination: StationDetails # Destination station information composition: SegmentComposition # Composition details of the segment @dataclass class CompositionApiResponse(ApiResponse): """Encapsulates the response containing composition details of a specific train.""" composition: list[Segment] = field(default_factory=list) # Composition details of the train @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Pre-deserialization hook to safely flatten composition segments.""" d["composition"] = d["composition"]["segments"]["segment"] return d @dataclass class DescriptionLink(DataClassORJSONMixin): """Represents a single link within a disturbance description.""" id: str # Link ID link: str # URL of the link text: str # Text displayed for the link @dataclass class Disturbance(DataClassORJSONMixin): """Represents a railway system disturbance, including description and metadata.""" id: str # ID of the disturbance title: str # Title of the disturbance description: str # Description of the disturbance type: DisturbanceType # Type of disturbance (e.g., "disturbance", "planned") link: str # Link to more information timestamp: datetime = field( metadata=field_options(deserialize=_timestamp_to_datetime) ) # Timestamp of the disturbance richtext: str # Rich-text description (HTML-like) description_links: list[DescriptionLink] = field( metadata=field_options(alias="descriptionLinks"), default_factory=list ) # List of description links @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Extract 'descriptionLink' list from 'descriptionLinks' before deserialization.""" d["descriptionLinks"] = d["descriptionLinks"]["descriptionLink"] return d @dataclass class DisturbancesApiResponse(ApiResponse): """Encapsulates multiple disturbances returned by the disturbances endpoint.""" disturbances: list[Disturbance] = field( metadata=field_options(alias="disturbance"), default_factory=list ) # List of disturbances