apyefa 0.0.1__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.

Potentially problematic release.


This version of apyefa might be problematic. Click here for more details.

apyefa/data_classes.py ADDED
@@ -0,0 +1,381 @@
1
+ from abc import abstractmethod
2
+ from dataclasses import dataclass, field
3
+ from datetime import date, datetime
4
+ from enum import IntEnum, StrEnum
5
+ from typing import Final, Self
6
+
7
+ import voluptuous as vol
8
+
9
+ from apyefa.helpers import parse_date, parse_datetime
10
+
11
+
12
+ # Enums
13
+ class LocationType(StrEnum):
14
+ STOP = "stop"
15
+ POI = "poi"
16
+ ADDRESS = "address"
17
+ STREET = "street"
18
+ LOCALITY = "locality"
19
+ SUBURB = "suburb"
20
+ PLATFORM = "platform"
21
+ UNKNOWN = "unknown"
22
+
23
+
24
+ class TransportType(IntEnum):
25
+ RAIL = 0 # RB
26
+ SUBURBAN = 1 # S-Bahn
27
+ SUBWAY = 2 # U-Bahn
28
+ CITY_RAIL = 3 # Stadtbahn
29
+ TRAM = 4 # Straßenbahn
30
+ BUS = 5 # Bus
31
+ RBUS = 6 # Regional Bus
32
+ EXPRESS_BUS = 7 # Schnellbus
33
+ CABLE_TRAM = 8 # Seilbahn
34
+ FERRY = 9 # Schief
35
+ AST = 10 # Anruf-Sammel-Taxi
36
+
37
+
38
+ class LocationFilter(IntEnum):
39
+ NO_FILTER = 0
40
+ LOCATIONS = 1
41
+ STOPS = 2
42
+ STREETS = 4
43
+ ADDRESSES = 8
44
+ INTERSACTIONS = 16
45
+ POIS = 32
46
+ POST_CODES = 64
47
+
48
+
49
+ class CoordFormat(StrEnum):
50
+ WGS84 = "WGS84[dd.ddddd]"
51
+
52
+
53
+ # Validation schemas
54
+ def IsLocationType(type: str):
55
+ if type not in [x.value for x in LocationFilter]:
56
+ raise ValueError
57
+
58
+
59
+ SCHEMA_PROPERTIES = vol.Schema(
60
+ {
61
+ vol.Required("stopId"): str,
62
+ vol.Optional("downloads"): list,
63
+ vol.Optional("area"): str,
64
+ vol.Optional("platform"): str,
65
+ vol.Optional("platformName"): str,
66
+ }
67
+ )
68
+
69
+ SCHEMA_LINE_PROPERTIES: Final = vol.Schema(
70
+ {
71
+ vol.Required("globalId"): str,
72
+ vol.Required("isROP"): bool,
73
+ vol.Required("isSTT"): bool,
74
+ vol.Required("isTTB"): bool,
75
+ vol.Required("lineDisplay"): str,
76
+ vol.Required("timetablePeriod"): str,
77
+ vol.Required("tripCode"): int,
78
+ vol.Required("validity"): vol.Schema(
79
+ {
80
+ vol.Required("from"): vol.Date("%Y-%m-%d"),
81
+ vol.Required("to"): vol.Date("%Y-%m-%d"),
82
+ }
83
+ ),
84
+ }
85
+ )
86
+
87
+ SCHEMA_PRODUCT = vol.Schema(
88
+ {
89
+ vol.Required("id"): int,
90
+ vol.Required("class"): int,
91
+ vol.Required("name"): str,
92
+ vol.Optional("iconId"): int,
93
+ }
94
+ )
95
+
96
+ SCHEMA_STOP = vol.Schema(
97
+ {
98
+ vol.Required("name"): str,
99
+ vol.Required("type"): IsLocationType,
100
+ vol.Optional("id"): str,
101
+ }
102
+ )
103
+
104
+ SCHEMA_PARENT = vol.Schema(
105
+ {
106
+ vol.Required("name"): str,
107
+ vol.Required("type"): str,
108
+ vol.Optional("id"): str,
109
+ vol.Optional("isGlobalId"): vol.Boolean,
110
+ vol.Optional("disassembledName"): str,
111
+ vol.Optional("parent"): vol.Self,
112
+ vol.Optional("properties"): SCHEMA_PROPERTIES,
113
+ }
114
+ )
115
+
116
+ SCHEMA_OPERATOR = vol.Schema(
117
+ {
118
+ vol.Required("id"): str,
119
+ vol.Required("name"): str,
120
+ vol.Optional("code"): str,
121
+ }
122
+ )
123
+
124
+ SCHEMA_LOCATION: Final = vol.Schema(
125
+ {
126
+ vol.Required("name"): str,
127
+ vol.Required("type"): vol.In([x.value for x in LocationType]),
128
+ vol.Optional("id"): str,
129
+ vol.Optional("disassembledName"): str,
130
+ vol.Optional("coord"): list,
131
+ vol.Optional("isGlobalId"): vol.Boolean,
132
+ vol.Optional("isBest"): vol.Boolean,
133
+ vol.Optional("productClasses"): list[vol.Range(min=0, max=10)],
134
+ vol.Optional("parent"): SCHEMA_PARENT,
135
+ vol.Optional("assignedStops"): [vol.Self],
136
+ vol.Optional("properties"): SCHEMA_PROPERTIES,
137
+ vol.Optional("matchQuality"): int,
138
+ },
139
+ extra=vol.ALLOW_EXTRA,
140
+ )
141
+
142
+ SCHEMA_TRANSPORTATION: Final = vol.Schema(
143
+ {
144
+ vol.Required("id"): str,
145
+ vol.Required("name"): str,
146
+ vol.Required("disassembledName"): str,
147
+ vol.Required("number"): str,
148
+ vol.Required("description"): str,
149
+ vol.Required("product"): SCHEMA_PRODUCT,
150
+ vol.Optional("operator"): SCHEMA_OPERATOR,
151
+ vol.Optional("destination"): SCHEMA_LOCATION,
152
+ vol.Optional("origin"): SCHEMA_LOCATION,
153
+ vol.Optional("properties"): dict,
154
+ }
155
+ )
156
+
157
+ SCHEMA_SYSTEM_INFO: Final = vol.Schema(
158
+ {
159
+ vol.Required("version"): str,
160
+ vol.Required("ptKernel"): vol.Schema(
161
+ {
162
+ vol.Required("appVersion"): str,
163
+ vol.Required("dataFormat"): str,
164
+ vol.Required("dataBuild"): str,
165
+ }
166
+ ),
167
+ vol.Required("validity"): vol.Schema(
168
+ {
169
+ vol.Required("from"): vol.Date("%Y-%m-%d"),
170
+ vol.Required("to"): vol.Date("%Y-%m-%d"),
171
+ }
172
+ ),
173
+ }
174
+ )
175
+
176
+ SCHEMA_DEPARTURE: Final = vol.Schema(
177
+ {
178
+ vol.Required("location"): SCHEMA_LOCATION,
179
+ vol.Required("departureTimePlanned"): vol.Datetime("%Y-%m-%dT%H:%M:%S%z"),
180
+ vol.Optional("departureTimeEstimated"): vol.Datetime("%Y-%m-%dT%H:%M:%S%z"),
181
+ vol.Required("transportation"): SCHEMA_TRANSPORTATION,
182
+ vol.Optional("infos"): list,
183
+ }
184
+ )
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class _Base:
189
+ raw_data: dict = field(repr=False)
190
+
191
+ @classmethod
192
+ @abstractmethod
193
+ def from_dict(cls, data: dict):
194
+ raise NotImplementedError
195
+
196
+ def to_dict(self) -> dict:
197
+ return self.raw_data
198
+
199
+
200
+ # Data classes
201
+ @dataclass(frozen=True)
202
+ class SystemInfo(_Base):
203
+ version: str
204
+ app_version: str
205
+ data_format: str
206
+ data_build: str
207
+ valid_from: date
208
+ valid_to: date
209
+
210
+ _schema = SCHEMA_SYSTEM_INFO
211
+
212
+ @classmethod
213
+ def from_dict(cls, data: dict) -> Self | None:
214
+ if not data:
215
+ return None
216
+
217
+ if not isinstance(data, dict):
218
+ raise ValueError(f"Expected a dictionary, provided {type(data)}")
219
+
220
+ cls._schema(data)
221
+
222
+ return SystemInfo(
223
+ data,
224
+ data.get("version"),
225
+ data.get("ptKernel").get("appVersion"),
226
+ data.get("ptKernel").get("dataFormat"),
227
+ data.get("ptKernel").get("dataBuild"),
228
+ parse_date(data.get("validity").get("from")),
229
+ parse_date(data.get("validity").get("to")),
230
+ )
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class Location(_Base):
235
+ name: str
236
+ loc_type: LocationType
237
+ id: str = ""
238
+ coord: list[int] = field(default_factory=[])
239
+ transports: list[TransportType] = field(default_factory=[])
240
+ parent: Self | None = None
241
+ stops: list[Self] = field(default_factory=[])
242
+ properties: dict = field(default_factory={})
243
+ disassembled_name: str = field(repr=False, default="")
244
+ match_quality: int = field(repr=False, default=0)
245
+
246
+ _schema = SCHEMA_LOCATION
247
+
248
+ @classmethod
249
+ def from_dict(cls, data: dict) -> Self | None:
250
+ if not data:
251
+ return None
252
+
253
+ if not isinstance(data, dict):
254
+ raise ValueError(f"Expected a dictionary, provided {type(data)}")
255
+
256
+ # validate data dictionary
257
+ cls._schema(data)
258
+
259
+ name = data.get("name")
260
+ id = data.get("id", "")
261
+ loc_type = LocationType(data.get("type", "unknown"))
262
+ disassembled_name = data.get("disassembledName", None)
263
+ coord = data.get("coord", [])
264
+ match_quality = data.get("matchQuality", 0)
265
+ transports = [TransportType(x) for x in data.get("productClasses", [])]
266
+ properties = data.get("properties", {})
267
+ parent = Location.from_dict(data.get("parent"))
268
+ stops = [Location.from_dict(x) for x in data.get("assignedStops", [])]
269
+
270
+ return Location(
271
+ data,
272
+ name,
273
+ loc_type,
274
+ id,
275
+ coord,
276
+ transports,
277
+ parent,
278
+ stops,
279
+ properties,
280
+ disassembled_name,
281
+ match_quality,
282
+ )
283
+
284
+
285
+ @dataclass(frozen=True)
286
+ class Departure(_Base):
287
+ location: Location = field(repr=False)
288
+ line_name: str
289
+ route: str
290
+ origin: Location
291
+ destination: Location
292
+ transport: TransportType
293
+ planned_time: datetime
294
+ estimated_time: datetime | None = None
295
+ infos: list[dict] = field(default_factory=[])
296
+
297
+ _schema = SCHEMA_DEPARTURE
298
+
299
+ @classmethod
300
+ def from_dict(cls, data: dict) -> Self | None:
301
+ if not data:
302
+ return None
303
+
304
+ if not isinstance(data, dict):
305
+ raise ValueError(f"Expected a dictionary, provided {type(data)}")
306
+
307
+ # validate data dictionary
308
+ cls._schema(data)
309
+
310
+ location = Location.from_dict(data.get("location"))
311
+ planned_time = parse_datetime(data.get("departureTimePlanned", None))
312
+ estimated_time = parse_datetime(data.get("departureTimeEstimated", None))
313
+ infos = data.get("infos")
314
+
315
+ line = Line.from_dict(data.get("transportation"))
316
+ line_name = line.name
317
+ transport = line.product
318
+ origin = line.origin
319
+ destination = line.destination
320
+ route = line.description
321
+
322
+ return Departure(
323
+ data,
324
+ location,
325
+ line_name,
326
+ route,
327
+ origin,
328
+ destination,
329
+ transport,
330
+ planned_time,
331
+ estimated_time,
332
+ infos,
333
+ )
334
+
335
+
336
+ @dataclass(frozen=True)
337
+ class Line(_Base):
338
+ id: str
339
+ name: str
340
+ description: str
341
+ product: TransportType
342
+ operator: str
343
+ destination: Location
344
+ origin: Location
345
+ properties: dict = field(default_factory={})
346
+
347
+ _schema = SCHEMA_TRANSPORTATION
348
+
349
+ @classmethod
350
+ def from_dict(cls, data: dict) -> Self | None:
351
+ if not data:
352
+ return None
353
+
354
+ if not isinstance(data, dict):
355
+ raise ValueError(f"Expected a dictionary, provided {type(data)}")
356
+
357
+ # validate data dictionary
358
+ cls._schema(data)
359
+
360
+ id = data.get("id")
361
+ name = data.get("number")
362
+ # disassembled_name = data.get("disassembledName")
363
+ # number = data.get("number")
364
+ description = data.get("description")
365
+ product = TransportType(data.get("product").get("class"))
366
+ operator = data.get("operator").get("name")
367
+ destination = Location.from_dict(data.get("destination"))
368
+ origin = Location.from_dict(data.get("origin"))
369
+ properties = data.get("properties", {})
370
+
371
+ return Line(
372
+ data,
373
+ id,
374
+ name,
375
+ description,
376
+ product,
377
+ operator,
378
+ destination,
379
+ origin,
380
+ properties,
381
+ )
apyefa/exceptions.py ADDED
@@ -0,0 +1,18 @@
1
+ class EfaConnectionError(IOError):
2
+ pass
3
+
4
+
5
+ class EfaParameterError(ValueError):
6
+ pass
7
+
8
+
9
+ class EfaParseError(AttributeError):
10
+ pass
11
+
12
+
13
+ class EfaResponseInvalid(ValueError):
14
+ pass
15
+
16
+
17
+ class EfaFormatNotSupported(Exception):
18
+ pass
apyefa/helpers.py ADDED
@@ -0,0 +1,74 @@
1
+ import datetime
2
+ import re
3
+ from zoneinfo import ZoneInfo
4
+
5
+ TZ_INFO = ZoneInfo("Europe/Berlin")
6
+
7
+
8
+ def parse_datetime(date: str) -> datetime.datetime:
9
+ if not date:
10
+ return None
11
+
12
+ dt = datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z")
13
+
14
+ return dt.astimezone(TZ_INFO)
15
+
16
+
17
+ def parse_date(date: str) -> datetime.date:
18
+ if not date:
19
+ return None
20
+
21
+ return datetime.datetime.strptime(date, "%Y-%m-%d").date()
22
+
23
+
24
+ def to_date(date: datetime.date) -> datetime.date:
25
+ return datetime.datetime.strftime(date, "%Y-%m-%d")
26
+
27
+
28
+ def is_datetime(date: str):
29
+ if not isinstance(date, str) or not date:
30
+ return False
31
+
32
+ pattern = re.compile(r"\d{8} \d{2}:\d{2}")
33
+
34
+ if not bool(pattern.match(date)):
35
+ return False
36
+
37
+ date_str = date.split(" ")[0]
38
+ time_str = date.split(" ")[1]
39
+
40
+ return is_date(date_str) and is_time(time_str)
41
+
42
+
43
+ def is_date(date: str):
44
+ if not isinstance(date, str) or not date:
45
+ return False
46
+
47
+ pattern = re.compile(r"(?P<year>\d{4})(?P<month>\d{2})(?P<day>\d{2})")
48
+
49
+ if not bool(pattern.match(date)):
50
+ return False
51
+
52
+ matches = pattern.search(date)
53
+
54
+ month = int(matches.group("month"))
55
+ day = int(matches.group("day"))
56
+
57
+ return (month >= 1 and month <= 12) and (day >= 1 and day <= 31)
58
+
59
+
60
+ def is_time(time: str):
61
+ if not isinstance(time, str) or not time:
62
+ return False
63
+
64
+ pattern = re.compile(r"(?P<hours>\d{2}):(?P<minutes>\d{2})")
65
+
66
+ if not bool(pattern.match(time)):
67
+ return False
68
+
69
+ matches = pattern.search(time)
70
+
71
+ hours = int(matches.group("hours"))
72
+ minutes = int(matches.group("minutes"))
73
+
74
+ return (hours >= 0 and hours < 24) and (minutes >= 0 and minutes < 60)
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Alex Jung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.