pyrailworks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ """pyrailworks - Python client for the Public RO Railworks API."""
2
+ from pyrailworks.client import AsyncRailworksClient, RailworksClient
3
+ from pyrailworks.exceptions import (
4
+ BadRequestError,
5
+ NotFoundError,
6
+ ProblemError,
7
+ RailworksError,
8
+ StationsLoadingError,
9
+ )
10
+ from pyrailworks.models import (
11
+ AlterationChange,
12
+ ChangeEvent,
13
+ ChangeEventType,
14
+ ChangesResponse,
15
+ DateInference,
16
+ DateInferenceMethod,
17
+ DaySection,
18
+ Effect,
19
+ EffectKind,
20
+ Extraction,
21
+ ExtractionStatus,
22
+ Health,
23
+ LineName,
24
+ NoticeResponse,
25
+ NoticeStatus,
26
+ NoticesResponse,
27
+ RawNoticeResponse,
28
+ Revision,
29
+ SegmentExpansion,
30
+ ServiceAlteration,
31
+ Source,
32
+ Station,
33
+ StationDay,
34
+ StationDaysResponse,
35
+ StationLine,
36
+ StationResponse,
37
+ StationsResponse,
38
+ TimeScope,
39
+ WorksNotice,
40
+ WorksSegment,
41
+ )
42
+
43
+ __all__ = [
44
+ "RailworksClient",
45
+ "AsyncRailworksClient",
46
+ "RailworksError",
47
+ "ProblemError",
48
+ "BadRequestError",
49
+ "NotFoundError",
50
+ "StationsLoadingError",
51
+ "LineName",
52
+ "NoticeStatus",
53
+ "EffectKind",
54
+ "AlterationChange",
55
+ "ExtractionStatus",
56
+ "DateInferenceMethod",
57
+ "SegmentExpansion",
58
+ "ChangeEventType",
59
+ "Source",
60
+ "DateInference",
61
+ "DaySection",
62
+ "WorksSegment",
63
+ "Effect",
64
+ "ServiceAlteration",
65
+ "Extraction",
66
+ "Revision",
67
+ "WorksNotice",
68
+ "ChangeEvent",
69
+ "StationLine",
70
+ "Station",
71
+ "TimeScope",
72
+ "StationDay",
73
+ "Health",
74
+ "NoticesResponse",
75
+ "NoticeResponse",
76
+ "RawNoticeResponse",
77
+ "ChangesResponse",
78
+ "StationsResponse",
79
+ "StationResponse",
80
+ "StationDaysResponse",
81
+ ]
pyrailworks/client.py ADDED
@@ -0,0 +1,453 @@
1
+ """HTTP Client implementation for pyrailworks."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import date, datetime
5
+ from typing import (
6
+ Any,
7
+ AsyncIterator,
8
+ Dict,
9
+ Iterator,
10
+ List,
11
+ Optional,
12
+ Union,
13
+ )
14
+ import httpx
15
+
16
+ from pyrailworks.exceptions import (
17
+ BadRequestError,
18
+ NotFoundError,
19
+ ProblemError,
20
+ RailworksError,
21
+ StationsLoadingError,
22
+ )
23
+ from pyrailworks.models import (
24
+ ChangeEvent,
25
+ ChangesResponse,
26
+ Health,
27
+ LineName,
28
+ NoticeResponse,
29
+ NoticeStatus,
30
+ NoticesResponse,
31
+ RawNoticeResponse,
32
+ StationDay,
33
+ StationDaysResponse,
34
+ StationResponse,
35
+ StationsResponse,
36
+ WorksNotice,
37
+ Station,
38
+ )
39
+
40
+ DEFAULT_BASE_URL = "https://railworks.paddez.com"
41
+
42
+
43
+ def _format_date(val: Union[date, str, None]) -> Optional[str]:
44
+ if val is None:
45
+ return None
46
+ if isinstance(val, (date, datetime)):
47
+ return val.strftime("%Y-%m-%d")
48
+ return str(val)
49
+
50
+
51
+ def _format_datetime(val: Union[datetime, str, None]) -> Optional[str]:
52
+ if val is None:
53
+ return None
54
+ if isinstance(val, datetime):
55
+ return val.isoformat()
56
+ return str(val)
57
+
58
+
59
+ def _format_status(
60
+ status: Union[NoticeStatus, str, List[Union[NoticeStatus, str]], None]
61
+ ) -> Optional[str]:
62
+ if status is None:
63
+ return None
64
+ if isinstance(status, (list, tuple, set)):
65
+ return ",".join(
66
+ s.value if isinstance(s, NoticeStatus) else str(s) for s in status
67
+ )
68
+ if isinstance(status, NoticeStatus):
69
+ return status.value
70
+ return str(status)
71
+
72
+
73
+ def _handle_error_response(response: httpx.Response) -> None:
74
+ if response.is_success or response.status_code == 304:
75
+ return
76
+
77
+ content_type = response.headers.get("content-type", "")
78
+ if "application/problem+json" in content_type:
79
+ try:
80
+ data = response.json()
81
+ title = data.get("title", "Error")
82
+ detail = data.get("detail", response.text)
83
+ type_ = data.get("type")
84
+ status = data.get("status", response.status_code)
85
+
86
+ if status == 400:
87
+ raise BadRequestError(status=status, title=title, detail=detail, type_=type_)
88
+ if status == 404:
89
+ raise NotFoundError(status=status, title=title, detail=detail, type_=type_)
90
+ if status == 503:
91
+ raise StationsLoadingError(status=status, title=title, detail=detail, type_=type_)
92
+
93
+ raise ProblemError(status=status, title=title, detail=detail, type_=type_)
94
+ except (ValueError, KeyError):
95
+ pass
96
+
97
+ response.raise_for_status()
98
+
99
+
100
+ class RailworksClient:
101
+ """Synchronous client for the Railworks API."""
102
+
103
+ def __init__(
104
+ self,
105
+ base_url: str = DEFAULT_BASE_URL,
106
+ *,
107
+ timeout: float = 30.0,
108
+ client: Optional[httpx.Client] = None,
109
+ headers: Optional[Dict[str, str]] = None,
110
+ ) -> None:
111
+ self.base_url = base_url.rstrip("/")
112
+ default_headers = {
113
+ "User-Agent": "pyrailworks/0.1.0",
114
+ "Accept": "application/json",
115
+ }
116
+ if headers:
117
+ default_headers.update(headers)
118
+
119
+ if client is not None:
120
+ self._client = client
121
+ self._owns_client = False
122
+ else:
123
+ self._client = httpx.Client(
124
+ base_url=self.base_url,
125
+ timeout=timeout,
126
+ headers=default_headers,
127
+ follow_redirects=True,
128
+ )
129
+ self._owns_client = True
130
+
131
+ def close(self) -> None:
132
+ if self._owns_client:
133
+ self._client.close()
134
+
135
+ def __enter__(self) -> RailworksClient:
136
+ return self
137
+
138
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
139
+ self.close()
140
+
141
+ def _get(self, path: str, params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None) -> httpx.Response:
142
+ filtered_params = {k: v for k, v in (params or {}).items() if v is not None}
143
+ resp = self._client.get(path, params=filtered_params, headers=headers)
144
+ _handle_error_response(resp)
145
+ return resp
146
+
147
+ def get_health(self) -> Health:
148
+ """Get health status, freshness and backend diagnostics."""
149
+ resp = self._get("/v1/health")
150
+ return Health.model_validate(resp.json())
151
+
152
+ def list_notices(
153
+ self,
154
+ *,
155
+ from_date: Union[date, str, None] = None,
156
+ to_date: Union[date, str, None] = None,
157
+ status: Union[NoticeStatus, str, List[Union[NoticeStatus, str]], None] = None,
158
+ station: Optional[str] = None,
159
+ line: Union[LineName, str, None] = None,
160
+ group: Optional[str] = None,
161
+ min_confidence: Optional[float] = None,
162
+ ) -> NoticesResponse:
163
+ """List Works Notices with any day in the given window."""
164
+ params: Dict[str, Any] = {
165
+ "from": _format_date(from_date),
166
+ "to": _format_date(to_date),
167
+ "status": _format_status(status),
168
+ "station": station,
169
+ "line": line.value if isinstance(line, LineName) else line,
170
+ "group": group,
171
+ "min_confidence": min_confidence,
172
+ }
173
+ resp = self._get("/v1/notices", params=params)
174
+ return NoticesResponse.model_validate(resp.json())
175
+
176
+ def get_notice(self, notice_id: str) -> WorksNotice:
177
+ """Get a full Works Notice by id with revisions, day sections and alterations."""
178
+ resp = self._get(f"/v1/notices/{notice_id}")
179
+ data = resp.json()
180
+ return WorksNotice.model_validate(data["notice"])
181
+
182
+ def get_notice_raw(self, notice_id: str) -> RawNoticeResponse:
183
+ """Get verbatim upstream HTML for all revisions of a notice."""
184
+ resp = self._get(f"/v1/notices/{notice_id}/raw")
185
+ return RawNoticeResponse.model_validate(resp.json())
186
+
187
+ def list_changes(
188
+ self,
189
+ *,
190
+ after: Optional[str] = None,
191
+ since: Union[datetime, str, None] = None,
192
+ limit: Optional[int] = None,
193
+ ) -> ChangesResponse:
194
+ """Fetch a page of change events from the append-only change log."""
195
+ params: Dict[str, Any] = {
196
+ "after": after,
197
+ "since": _format_datetime(since),
198
+ "limit": limit,
199
+ }
200
+ resp = self._get("/v1/changes", params=params)
201
+ return ChangesResponse.model_validate(resp.json())
202
+
203
+ def iter_changes(
204
+ self,
205
+ *,
206
+ since: Union[datetime, str, None] = None,
207
+ after: Optional[str] = None,
208
+ limit: int = 500,
209
+ ) -> Iterator[ChangeEvent]:
210
+ """Convenience generator that yields change events and automatically traverses pages."""
211
+ cursor = after
212
+ initial_since = since if cursor is None else None
213
+
214
+ while True:
215
+ resp = self.list_changes(after=cursor, since=initial_since, limit=limit)
216
+ initial_since = None
217
+ if not resp.events:
218
+ break
219
+ for event in resp.events:
220
+ yield event
221
+ if resp.next == cursor:
222
+ break
223
+ cursor = resp.next
224
+
225
+ def list_stations(self) -> List[Station]:
226
+ """List all Irish Rail stations served by timetabled trains."""
227
+ resp = self._get("/v1/stations")
228
+ data = resp.json()
229
+ return StationsResponse.model_validate(data).stations
230
+
231
+ def get_station(self, code: str) -> Station:
232
+ """Get details for a single station by code."""
233
+ resp = self._get(f"/v1/stations/{code}")
234
+ data = resp.json()
235
+ return StationResponse.model_validate(data).station
236
+
237
+ def get_station_days(
238
+ self,
239
+ code: str,
240
+ *,
241
+ from_date: Union[date, str, None] = None,
242
+ to_date: Union[date, str, None] = None,
243
+ ) -> StationDaysResponse:
244
+ """Check whether a station is affected, and on which dates."""
245
+ params: Dict[str, Any] = {
246
+ "from": _format_date(from_date),
247
+ "to": _format_date(to_date),
248
+ }
249
+ resp = self._get(f"/v1/stations/{code}/days", params=params)
250
+ return StationDaysResponse.model_validate(resp.json())
251
+
252
+ def get_calendar_feed(
253
+ self,
254
+ *,
255
+ from_date: Union[date, str, None] = None,
256
+ station: Optional[str] = None,
257
+ line: Union[LineName, str, None] = None,
258
+ group: Optional[str] = None,
259
+ min_confidence: Optional[float] = None,
260
+ ) -> str:
261
+ """Download the raw iCalendar (ICS) text."""
262
+ params: Dict[str, Any] = {
263
+ "from": _format_date(from_date),
264
+ "station": station,
265
+ "line": line.value if isinstance(line, LineName) else line,
266
+ "group": group,
267
+ "min_confidence": min_confidence,
268
+ }
269
+ resp = self._get(
270
+ "/v1/feeds/notices.ics",
271
+ params=params,
272
+ headers={"Accept": "text/calendar, text/plain"},
273
+ )
274
+ return resp.text
275
+
276
+
277
+ class AsyncRailworksClient:
278
+ """Asynchronous client for the Railworks API."""
279
+
280
+ def __init__(
281
+ self,
282
+ base_url: str = DEFAULT_BASE_URL,
283
+ *,
284
+ timeout: float = 30.0,
285
+ client: Optional[httpx.AsyncClient] = None,
286
+ headers: Optional[Dict[str, str]] = None,
287
+ ) -> None:
288
+ self.base_url = base_url.rstrip("/")
289
+ default_headers = {
290
+ "User-Agent": "pyrailworks/0.1.0",
291
+ "Accept": "application/json",
292
+ }
293
+ if headers:
294
+ default_headers.update(headers)
295
+
296
+ if client is not None:
297
+ self._client = client
298
+ self._owns_client = False
299
+ else:
300
+ self._client = httpx.AsyncClient(
301
+ base_url=self.base_url,
302
+ timeout=timeout,
303
+ headers=default_headers,
304
+ follow_redirects=True,
305
+ )
306
+ self._owns_client = True
307
+
308
+ async def close(self) -> None:
309
+ if self._owns_client:
310
+ await self._client.aclose()
311
+
312
+ async def __aenter__(self) -> AsyncRailworksClient:
313
+ return self
314
+
315
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
316
+ await self.close()
317
+
318
+ async def _get(
319
+ self, path: str, params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None
320
+ ) -> httpx.Response:
321
+ filtered_params = {k: v for k, v in (params or {}).items() if v is not None}
322
+ resp = await self._client.get(path, params=filtered_params, headers=headers)
323
+ _handle_error_response(resp)
324
+ return resp
325
+
326
+ async def get_health(self) -> Health:
327
+ """Get health status, freshness and backend diagnostics."""
328
+ resp = await self._get("/v1/health")
329
+ return Health.model_validate(resp.json())
330
+
331
+ async def list_notices(
332
+ self,
333
+ *,
334
+ from_date: Union[date, str, None] = None,
335
+ to_date: Union[date, str, None] = None,
336
+ status: Union[NoticeStatus, str, List[Union[NoticeStatus, str]], None] = None,
337
+ station: Optional[str] = None,
338
+ line: Union[LineName, str, None] = None,
339
+ group: Optional[str] = None,
340
+ min_confidence: Optional[float] = None,
341
+ ) -> NoticesResponse:
342
+ """List Works Notices with any day in the given window."""
343
+ params: Dict[str, Any] = {
344
+ "from": _format_date(from_date),
345
+ "to": _format_date(to_date),
346
+ "status": _format_status(status),
347
+ "station": station,
348
+ "line": line.value if isinstance(line, LineName) else line,
349
+ "group": group,
350
+ "min_confidence": min_confidence,
351
+ }
352
+ resp = await self._get("/v1/notices", params=params)
353
+ return NoticesResponse.model_validate(resp.json())
354
+
355
+ async def get_notice(self, notice_id: str) -> WorksNotice:
356
+ """Get a full Works Notice by id with revisions, day sections and alterations."""
357
+ resp = await self._get(f"/v1/notices/{notice_id}")
358
+ data = resp.json()
359
+ return WorksNotice.model_validate(data["notice"])
360
+
361
+ async def get_notice_raw(self, notice_id: str) -> RawNoticeResponse:
362
+ """Get verbatim upstream HTML for all revisions of a notice."""
363
+ resp = await self._get(f"/v1/notices/{notice_id}/raw")
364
+ return RawNoticeResponse.model_validate(resp.json())
365
+
366
+ async def list_changes(
367
+ self,
368
+ *,
369
+ after: Optional[str] = None,
370
+ since: Union[datetime, str, None] = None,
371
+ limit: Optional[int] = None,
372
+ ) -> ChangesResponse:
373
+ """Fetch a page of change events from the append-only change log."""
374
+ params: Dict[str, Any] = {
375
+ "after": after,
376
+ "since": _format_datetime(since),
377
+ "limit": limit,
378
+ }
379
+ resp = await self._get("/v1/changes", params=params)
380
+ return ChangesResponse.model_validate(resp.json())
381
+
382
+ async def iter_changes(
383
+ self,
384
+ *,
385
+ since: Union[datetime, str, None] = None,
386
+ after: Optional[str] = None,
387
+ limit: int = 500,
388
+ ) -> AsyncIterator[ChangeEvent]:
389
+ """Convenience async generator that yields change events and automatically traverses pages."""
390
+ cursor = after
391
+ initial_since = since if cursor is None else None
392
+
393
+ while True:
394
+ resp = await self.list_changes(after=cursor, since=initial_since, limit=limit)
395
+ initial_since = None
396
+ if not resp.events:
397
+ break
398
+ for event in resp.events:
399
+ yield event
400
+ if resp.next == cursor:
401
+ break
402
+ cursor = resp.next
403
+
404
+ async def list_stations(self) -> List[Station]:
405
+ """List all Irish Rail stations served by timetabled trains."""
406
+ resp = await self._get("/v1/stations")
407
+ data = resp.json()
408
+ return StationsResponse.model_validate(data).stations
409
+
410
+ async def get_station(self, code: str) -> Station:
411
+ """Get details for a single station by code."""
412
+ resp = await self._get(f"/v1/stations/{code}")
413
+ data = resp.json()
414
+ return StationResponse.model_validate(data).station
415
+
416
+ async def get_station_days(
417
+ self,
418
+ code: str,
419
+ *,
420
+ from_date: Union[date, str, None] = None,
421
+ to_date: Union[date, str, None] = None,
422
+ ) -> StationDaysResponse:
423
+ """Check whether a station is affected, and on which dates."""
424
+ params: Dict[str, Any] = {
425
+ "from": _format_date(from_date),
426
+ "to": _format_date(to_date),
427
+ }
428
+ resp = await self._get(f"/v1/stations/{code}/days", params=params)
429
+ return StationDaysResponse.model_validate(resp.json())
430
+
431
+ async def get_calendar_feed(
432
+ self,
433
+ *,
434
+ from_date: Union[date, str, None] = None,
435
+ station: Optional[str] = None,
436
+ line: Union[LineName, str, None] = None,
437
+ group: Optional[str] = None,
438
+ min_confidence: Optional[float] = None,
439
+ ) -> str:
440
+ """Download the raw iCalendar (ICS) text."""
441
+ params: Dict[str, Any] = {
442
+ "from": _format_date(from_date),
443
+ "station": station,
444
+ "line": line.value if isinstance(line, LineName) else line,
445
+ "group": group,
446
+ "min_confidence": min_confidence,
447
+ }
448
+ resp = await self._get(
449
+ "/v1/feeds/notices.ics",
450
+ params=params,
451
+ headers={"Accept": "text/calendar, text/plain"},
452
+ )
453
+ return resp.text
@@ -0,0 +1,35 @@
1
+ """Exception classes for pyrailworks."""
2
+ from typing import Optional
3
+
4
+
5
+ class RailworksError(Exception):
6
+ """Base exception for all railworks errors."""
7
+
8
+
9
+ class ProblemError(RailworksError):
10
+ """RFC 9457 Problem Details error returned by the Railworks API."""
11
+
12
+ def __init__(
13
+ self,
14
+ status: int,
15
+ title: str,
16
+ detail: str,
17
+ type_: Optional[str] = None,
18
+ ) -> None:
19
+ self.status = status
20
+ self.title = title
21
+ self.detail = detail
22
+ self.type_ = type_
23
+ super().__init__(f"[{status}] {title}: {detail}")
24
+
25
+
26
+ class BadRequestError(ProblemError):
27
+ """400 Bad Request: bad or unknown query parameter."""
28
+
29
+
30
+ class NotFoundError(ProblemError):
31
+ """404 Not Found: no such notice, station or resource."""
32
+
33
+
34
+ class StationsLoadingError(ProblemError):
35
+ """503 Service Unavailable: station list hasn't loaded yet."""
pyrailworks/models.py ADDED
@@ -0,0 +1,286 @@
1
+ """Pydantic data models for the Railworks API."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import date, datetime
5
+ from enum import Enum
6
+ from typing import Any, List, Optional
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class LineName(str, Enum):
11
+ DART = "dart"
12
+ NORTHERN_COMMUTERS = "northern-commuters"
13
+ BELFAST = "belfast"
14
+ ROSSLARE = "rosslare"
15
+ MAYNOOTH_LONGFORD_SLIGO = "maynooth-longford-sligo"
16
+ PORTLAOISE_COMMUTERS = "portlaoise-commuters"
17
+ CORK_LIMERICK_TRALEE = "cork-limerick-tralee"
18
+ COBH_MIDLETON = "cobh-midleton"
19
+ GALWAY_WESTPORT = "galway-westport"
20
+ WATERFORD = "waterford"
21
+ WESTERN_RAIL_CORRIDOR = "western-rail-corridor"
22
+ WATERFORD_TO_LIMERICK = "waterford-to-limerick"
23
+ BALLYBROPHY = "ballybrophy"
24
+
25
+
26
+ class NoticeStatus(str, Enum):
27
+ ACTIVE = "active"
28
+ EXPIRED = "expired"
29
+ WITHDRAWN = "withdrawn"
30
+
31
+
32
+ class EffectKind(str, Enum):
33
+ NO_SERVICE = "no_service"
34
+ STATION_CLOSED = "station_closed"
35
+ BUS_REPLACEMENT = "bus_replacement"
36
+ REDUCED_SERVICE = "reduced_service"
37
+ OTHER = "other"
38
+
39
+
40
+ class AlterationChange(str, Enum):
41
+ CANCELLED = "cancelled"
42
+ BUS_REPLACED = "bus_replaced"
43
+ TERMINATES_SHORT = "terminates_short"
44
+ STARTS_SHORT = "starts_short"
45
+ RETIMED = "retimed"
46
+ SKIPS_STOPS = "skips_stops"
47
+ OTHER = "other"
48
+
49
+
50
+ class ExtractionStatus(str, Enum):
51
+ DISABLED = "disabled"
52
+ PENDING = "pending"
53
+ DONE = "done"
54
+ FAILED = "failed"
55
+
56
+
57
+ class DateInferenceMethod(str, Enum):
58
+ WEEKDAY = "weekday"
59
+ NEXT_OCCURRENCE = "next_occurrence"
60
+
61
+
62
+ class SegmentExpansion(str, Enum):
63
+ OK = "ok"
64
+ AMBIGUOUS = "ambiguous"
65
+ UNRESOLVED = "unresolved"
66
+
67
+
68
+ class ChangeEventType(str, Enum):
69
+ CREATED = "created"
70
+ REVISED = "revised"
71
+ EXPIRED = "expired"
72
+ WITHDRAWN = "withdrawn"
73
+ EXTRACTED = "extracted"
74
+
75
+
76
+ class Source(BaseModel):
77
+ built_at: Optional[datetime] = None
78
+ fetched_at: Optional[datetime] = None
79
+ checked_at: Optional[datetime] = None
80
+ stale: bool
81
+
82
+
83
+ class DateCorrection(BaseModel):
84
+ from_: date = Field(..., alias="from")
85
+ to: date
86
+
87
+
88
+ class DateInference(BaseModel):
89
+ method: DateInferenceMethod
90
+ corrections: List[DateCorrection] = Field(default_factory=list)
91
+ conflicts: List[date] = Field(default_factory=list)
92
+ prose_missing: List[date] = Field(default_factory=list)
93
+ prose_extra: List[date] = Field(default_factory=list)
94
+
95
+
96
+ class DaySection(BaseModel):
97
+ heading: str
98
+ days: List[date] = Field(default_factory=list)
99
+
100
+
101
+ class WorksSegment(BaseModel):
102
+ clause: str
103
+ from_: Optional[str] = Field(None, alias="from")
104
+ to: Optional[str] = None
105
+ from_codes: List[str] = Field(default_factory=list)
106
+ to_codes: List[str] = Field(default_factory=list)
107
+ stations: List[str] = Field(default_factory=list)
108
+ from_time: Optional[str] = None
109
+ until_time: Optional[str] = None
110
+ expansion: SegmentExpansion
111
+ quote: str
112
+
113
+
114
+ class Effect(BaseModel):
115
+ kind: EffectKind
116
+ from_: Optional[str] = Field(None, alias="from")
117
+ to: Optional[str] = None
118
+ station_codes: List[str] = Field(default_factory=list)
119
+ from_time: Optional[str] = None
120
+ until_time: Optional[str] = None
121
+ quote: str
122
+
123
+
124
+ class ServiceAlteration(BaseModel):
125
+ departs: str
126
+ origin: str
127
+ destination: str
128
+ changes: List[AlterationChange] = Field(default_factory=list)
129
+ stations: List[str] = Field(default_factory=list)
130
+ retimed_to: Optional[str] = None
131
+ bus_departs: Optional[str] = None
132
+ quote: str
133
+ origin_codes: List[str] = Field(default_factory=list)
134
+ destination_codes: List[str] = Field(default_factory=list)
135
+ station_codes: List[str] = Field(default_factory=list)
136
+
137
+
138
+ class Extraction(BaseModel):
139
+ status: ExtractionStatus
140
+ extractor: Optional[str] = None
141
+ rejected: int = 0
142
+
143
+
144
+ class Revision(BaseModel):
145
+ build: datetime
146
+ seen_at: datetime
147
+ content_hash: str
148
+ trim: bool
149
+ summary: str
150
+ html: Optional[str] = None
151
+
152
+
153
+ class WorksNotice(BaseModel):
154
+ id: str
155
+ service_group: str
156
+ status: NoticeStatus
157
+ heading: str
158
+ days: List[date]
159
+ starts_on: date
160
+ ends_on: date
161
+ until: date
162
+ date_inference: DateInference
163
+ first_seen: datetime
164
+ last_seen: datetime
165
+ revision_count: int
166
+ works_segments: List[WorksSegment] = Field(default_factory=list)
167
+ effects: List[Effect] = Field(default_factory=list)
168
+ stations: List[str] = Field(default_factory=list)
169
+ confidence: float
170
+ extraction: Extraction
171
+ day_sections: Optional[List[DaySection]] = None
172
+ detail: Optional[str] = None
173
+ alterations: Optional[List[ServiceAlteration]] = None
174
+ revisions: Optional[List[Revision]] = None
175
+
176
+
177
+ class ChangeEvent(BaseModel):
178
+ cursor: str
179
+ type: ChangeEventType
180
+ notice_id: str
181
+ build: datetime
182
+ at: datetime
183
+ trim: Optional[bool] = None
184
+
185
+
186
+ class StationLine(BaseModel):
187
+ line: str
188
+ position: int
189
+
190
+
191
+ class Station(BaseModel):
192
+ code: str
193
+ name: str
194
+ names: List[str] = Field(default_factory=list)
195
+ alias_codes: List[str] = Field(default_factory=list)
196
+ lat: float
197
+ lon: float
198
+ lines: List[StationLine] = Field(default_factory=list)
199
+
200
+
201
+ class StationSummary(BaseModel):
202
+ code: str
203
+ name: str
204
+
205
+
206
+ class TimeScope(BaseModel):
207
+ from_: Optional[str] = Field(None, alias="from")
208
+ until: Optional[str] = None
209
+
210
+
211
+ class StationDay(BaseModel):
212
+ date: date
213
+ effect: EffectKind
214
+ time_scope: Optional[TimeScope] = None
215
+ notices: List[str] = Field(default_factory=list)
216
+
217
+
218
+ class NoticeRef(BaseModel):
219
+ id: str
220
+ service_group: str
221
+ heading: str
222
+
223
+
224
+ class LowConfidenceNoticeRef(NoticeRef):
225
+ confidence: Optional[float] = None
226
+
227
+
228
+ class LastFailure(BaseModel):
229
+ at: datetime
230
+ error: str
231
+
232
+
233
+ class HealthExtraction(BaseModel):
234
+ extractor: Optional[str] = None
235
+ pending: int = 0
236
+ failed: int = 0
237
+ last_error: str = ""
238
+
239
+
240
+ class Health(BaseModel):
241
+ source: Source
242
+ last_failure: Optional[LastFailure] = None
243
+ held_notices: List[NoticeRef] = Field(default_factory=list)
244
+ graph_removals: List[List[str]] = Field(default_factory=list)
245
+ unmapped_groups: List[str] = Field(default_factory=list)
246
+ low_confidence: List[LowConfidenceNoticeRef] = Field(default_factory=list)
247
+ unresolved_names: List[dict[str, Any]] = Field(default_factory=list)
248
+ extraction: HealthExtraction
249
+
250
+
251
+ # Response wrapper envelopes
252
+ class NoticesResponse(BaseModel):
253
+ source: Source
254
+ notices: List[WorksNotice]
255
+
256
+
257
+ class NoticeResponse(BaseModel):
258
+ source: Source
259
+ notice: WorksNotice
260
+
261
+
262
+ class RawNoticeResponse(BaseModel):
263
+ source: Source
264
+ revisions: List[Revision]
265
+
266
+
267
+ class ChangesResponse(BaseModel):
268
+ source: Source
269
+ events: List[ChangeEvent]
270
+ next: str
271
+
272
+
273
+ class StationsResponse(BaseModel):
274
+ source: Source
275
+ stations: List[Station]
276
+
277
+
278
+ class StationResponse(BaseModel):
279
+ source: Source
280
+ station: Station
281
+
282
+
283
+ class StationDaysResponse(BaseModel):
284
+ source: Source
285
+ station: StationSummary
286
+ days: List[StationDay]
pyrailworks/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyrailworks
3
+ Version: 0.1.0
4
+ Summary: Python client library for the Public RO Railworks Irish Rail Engineering Works API
5
+ Author: Irishsmurf
6
+ License-Expression: MIT
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx>=0.24.0
16
+ Requires-Dist: pydantic>=2.0.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
20
+ Requires-Dist: respx>=0.21.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pyrailworks
24
+
25
+ Python client library for the Public RO Railworks API ([https://railworks.paddez.com/docs](https://railworks.paddez.com/docs)).
26
+
27
+ Provides queryable access to Iarnród Éireann's (Irish Rail) planned engineering works, station days, change logs, and iCalendar feeds.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install pyrailworks
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ### Synchronous Client
38
+
39
+ ```python
40
+ from pyrailworks import RailworksClient, LineName
41
+
42
+ with RailworksClient() as client:
43
+ # 1. Health check
44
+ health = client.get_health()
45
+ print("Upstream checked at:", health.source.checked_at)
46
+
47
+ # 2. List stations
48
+ stations = client.list_stations()
49
+ print(f"Total stations: {len(stations)}")
50
+
51
+ # 3. Check affected days for a station
52
+ days_resp = client.get_station_days("WBROK")
53
+ for day in days_resp.days:
54
+ print(f"Station {days_resp.station.name} affected on {day.date}: {day.effect.value}")
55
+
56
+ # 4. List active notices on a line
57
+ notices = client.list_notices(line=LineName.DART, status="active")
58
+ for notice in notices.notices:
59
+ print(f"Notice {notice.id}: {notice.heading} ({notice.starts_on} to {notice.ends_on})")
60
+
61
+ # 5. Follow changes with automatic cursor traversal
62
+ for event in client.iter_changes():
63
+ print(f"Change event: {event.type.value} on notice {event.notice_id}")
64
+ ```
65
+
66
+ ### Asynchronous Client
67
+
68
+ ```python
69
+ import asyncio
70
+ from pyrailworks import AsyncRailworksClient
71
+
72
+ async def main():
73
+ async with AsyncRailworksClient() as client:
74
+ health = await client.get_health()
75
+ print("Backend stale:", health.source.stale)
76
+
77
+ async for event in client.iter_changes():
78
+ print("Notice changed:", event.notice_id)
79
+
80
+ asyncio.run(main())
81
+ ```
82
+
83
+ ## Features
84
+
85
+ - **Both Sync & Async**: `RailworksClient` and `AsyncRailworksClient` powered by `httpx`.
86
+ - **Strict Data Validation**: Pydantic v2 data models for type safety, validation, and auto-completion.
87
+ - **Problem Details Handling**: Full support for RFC 9457 errors with specific exceptions (`BadRequestError`, `NotFoundError`, `StationsLoadingError`).
88
+ - **Change Log Streaming**: Helper generators (`iter_changes()`) handling opaque cursor pagination.
89
+ - **Calendar Feeds**: Raw iCalendar text download via `get_calendar_feed()`.
@@ -0,0 +1,8 @@
1
+ pyrailworks/__init__.py,sha256=smjpJVrF39TffgALMiUNFfKGLBnOI-o6wBfKLaeFX1w,1654
2
+ pyrailworks/client.py,sha256=xlako8NWmDgwg8Wid2vWFfxaxvsCOmfeRqzIma5HRh8,15408
3
+ pyrailworks/exceptions.py,sha256=YS5XEO37jgp3crfCczkmcIkMmWnW6Mb3FYqdvjfngmQ,890
4
+ pyrailworks/models.py,sha256=IbRBJoqOQ5m6tUuUOKG4TdRln9ogTBEJZlzDMSCl69M,6954
5
+ pyrailworks/py.typed,sha256=bWew9mHgMy8LqMu7RuqQXFXLBxh2CRx0dUbSx-3wE48,27
6
+ pyrailworks-0.1.0.dist-info/METADATA,sha256=s5B4ogkhkNtySWHHaf6a_aEPWFmG4vJSdYs6GePPo78,3067
7
+ pyrailworks-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ pyrailworks-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any