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.
- pyrailworks/__init__.py +81 -0
- pyrailworks/client.py +453 -0
- pyrailworks/exceptions.py +35 -0
- pyrailworks/models.py +286 -0
- pyrailworks/py.typed +1 -0
- pyrailworks-0.1.0.dist-info/METADATA +89 -0
- pyrailworks-0.1.0.dist-info/RECORD +8 -0
- pyrailworks-0.1.0.dist-info/WHEEL +4 -0
pyrailworks/__init__.py
ADDED
|
@@ -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,,
|