koleo-cli 0.2.137.17__py3-none-any.whl → 0.2.137.18__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 koleo-cli might be problematic. Click here for more details.
- koleo/__init__.py +1 -1
- koleo/api/__init__.py +2 -0
- koleo/api/base.py +70 -0
- koleo/api/client.py +221 -0
- koleo/api/errors.py +46 -0
- koleo/api/logging.py +56 -0
- koleo/api/types.py +488 -0
- koleo/args.py +279 -0
- koleo/cli/__init__.py +9 -0
- koleo/cli/aliases.py +15 -0
- koleo/cli/base.py +103 -0
- koleo/cli/connections.py +142 -0
- koleo/cli/seats.py +103 -0
- koleo/cli/station_board.py +72 -0
- koleo/cli/stations.py +37 -0
- koleo/cli/train_info.py +142 -0
- koleo/cli/utils.py +27 -0
- koleo/storage.py +66 -12
- koleo/utils.py +95 -8
- {koleo_cli-0.2.137.17.dist-info → koleo_cli-0.2.137.18.dist-info}/METADATA +32 -13
- koleo_cli-0.2.137.18.dist-info/RECORD +26 -0
- {koleo_cli-0.2.137.17.dist-info → koleo_cli-0.2.137.18.dist-info}/WHEEL +1 -1
- koleo_cli-0.2.137.18.dist-info/entry_points.txt +2 -0
- koleo/api.py +0 -161
- koleo/cli.py +0 -608
- koleo/types.py +0 -237
- koleo_cli-0.2.137.17.dist-info/RECORD +0 -13
- koleo_cli-0.2.137.17.dist-info/entry_points.txt +0 -2
- {koleo_cli-0.2.137.17.dist-info → koleo_cli-0.2.137.18.dist-info}/licenses/LICENSE +0 -0
- {koleo_cli-0.2.137.17.dist-info → koleo_cli-0.2.137.18.dist-info}/top_level.txt +0 -0
koleo/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
from .api import KoleoAPI
|
|
2
|
-
from .types import *
|
|
2
|
+
from .api.types import *
|
koleo/api/__init__.py
ADDED
koleo/api/base.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from asyncio import sleep as asleep
|
|
2
|
+
|
|
3
|
+
from aiohttp import (
|
|
4
|
+
ClientConnectorError,
|
|
5
|
+
ClientOSError,
|
|
6
|
+
ClientResponse,
|
|
7
|
+
ClientResponseError,
|
|
8
|
+
ClientSession,
|
|
9
|
+
)
|
|
10
|
+
from orjson import loads
|
|
11
|
+
|
|
12
|
+
from .logging import LoggingMixin
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JsonableData(bytes):
|
|
16
|
+
response: ClientResponse
|
|
17
|
+
|
|
18
|
+
def __new__(cls, *args, response: ClientResponse, **kwargs):
|
|
19
|
+
obj = super().__new__(cls, *args, **kwargs)
|
|
20
|
+
obj.response = response
|
|
21
|
+
return obj
|
|
22
|
+
|
|
23
|
+
def json(self):
|
|
24
|
+
if "_json" not in self.__dict__:
|
|
25
|
+
self._json = loads(bytes(self))
|
|
26
|
+
return self._json
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaseAPIClient(LoggingMixin):
|
|
30
|
+
_session: ClientSession
|
|
31
|
+
|
|
32
|
+
exc = ClientResponseError
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def session(self) -> "ClientSession":
|
|
36
|
+
if not hasattr(self, "_session"):
|
|
37
|
+
self._session = ClientSession()
|
|
38
|
+
return self._session
|
|
39
|
+
|
|
40
|
+
async def close(self):
|
|
41
|
+
return await self.session.close()
|
|
42
|
+
|
|
43
|
+
async def exc_getter(self, r: ClientResponse) -> Exception | None:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
async def request(self, method, url: str, *args, retries: int = 4, fail_wait: float = 8, **kwargs) -> JsonableData:
|
|
47
|
+
try:
|
|
48
|
+
async with self.session.request(method, url, *args, **kwargs) as r:
|
|
49
|
+
if not r.ok:
|
|
50
|
+
self.dl(r.headers)
|
|
51
|
+
try:
|
|
52
|
+
self.dl(await r.text())
|
|
53
|
+
except UnicodeDecodeError:
|
|
54
|
+
self.dl("Response is not text!")
|
|
55
|
+
if exc := (await self.exc_getter(r)):
|
|
56
|
+
raise exc
|
|
57
|
+
r.raise_for_status()
|
|
58
|
+
return JsonableData(await r.read(), response=r)
|
|
59
|
+
except (ClientConnectorError, ClientOSError) as e:
|
|
60
|
+
if retries > 0:
|
|
61
|
+
await asleep(fail_wait)
|
|
62
|
+
return await self.request(
|
|
63
|
+
method,
|
|
64
|
+
url,
|
|
65
|
+
*args,
|
|
66
|
+
retries=retries - 1,
|
|
67
|
+
fail_wait=fail_wait,
|
|
68
|
+
**kwargs,
|
|
69
|
+
)
|
|
70
|
+
raise e
|
koleo/api/client.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from aiohttp import ClientResponse
|
|
5
|
+
|
|
6
|
+
from koleo.api.types import *
|
|
7
|
+
|
|
8
|
+
from .base import BaseAPIClient
|
|
9
|
+
from .errors import errors
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KoleoAPI(BaseAPIClient):
|
|
13
|
+
errors = errors
|
|
14
|
+
|
|
15
|
+
def __init__(self, auth: dict[str, str] | None = None) -> None:
|
|
16
|
+
self.base_url = "https://koleo.pl"
|
|
17
|
+
self.version = 2
|
|
18
|
+
self.base_headers = {
|
|
19
|
+
"x-koleo-version": str(self.version),
|
|
20
|
+
"User-Agent": "Koleo-CLI(https://pypi.org/project/koleo-cli)",
|
|
21
|
+
}
|
|
22
|
+
self._auth: dict[str, str] | None = auth
|
|
23
|
+
self._auth_valid: bool | None = None
|
|
24
|
+
|
|
25
|
+
async def get(self, path, use_auth: bool = False, *args, **kwargs):
|
|
26
|
+
headers = {**self.base_headers, **kwargs.pop("headers", {})}
|
|
27
|
+
if self._auth and use_auth:
|
|
28
|
+
headers["cookie"] = "; ".join([f"{k}={v}" for k, v in self._auth.items()])
|
|
29
|
+
r = await self.request("GET", self.base_url + path, headers=headers, *args, **kwargs)
|
|
30
|
+
if len(r) == 0:
|
|
31
|
+
raise self.errors.KoleoNotFound(r.response)
|
|
32
|
+
return r
|
|
33
|
+
|
|
34
|
+
async def post(self, path, use_auth: bool = False, *args, **kwargs):
|
|
35
|
+
headers = {**self.base_headers, **kwargs.pop("headers", {})}
|
|
36
|
+
if self._auth and use_auth:
|
|
37
|
+
headers["cookie"] = ("; ".join([f"{k}={v}" for k, v in self._auth.items()]),)
|
|
38
|
+
r = await self.request("POST", self.base_url + path, headers=headers, *args, **kwargs)
|
|
39
|
+
if len(r) == 0:
|
|
40
|
+
raise self.errors.KoleoNotFound(r.response)
|
|
41
|
+
return r
|
|
42
|
+
|
|
43
|
+
async def exc_getter(self, r: ClientResponse) -> Exception | None:
|
|
44
|
+
return await self.errors.from_response(r)
|
|
45
|
+
|
|
46
|
+
async def _require_auth(self) -> t.Literal[True]:
|
|
47
|
+
if self._auth is None:
|
|
48
|
+
raise errors.AuthRequired()
|
|
49
|
+
if self._auth_valid is None:
|
|
50
|
+
await self.get_current_session()
|
|
51
|
+
self._auth_valid = True
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
async def get_stations(self) -> list[ExtendedStationInfo]:
|
|
55
|
+
return (await self.get("/api/v2/main/stations")).json()
|
|
56
|
+
|
|
57
|
+
async def find_station(self, query: str, language: str = "pl") -> list[SearchStationInfo]:
|
|
58
|
+
# https://koleo.pl/ls?q=tere&language=pl
|
|
59
|
+
return (await self.get("/ls", params={"q": query, "language": language})).json()["stations"]
|
|
60
|
+
|
|
61
|
+
async def get_station_by_id(self, id: int) -> ExtendedStationInfo:
|
|
62
|
+
# https://koleo.pl/api/v2/main/stations/by_id/24000
|
|
63
|
+
return (
|
|
64
|
+
await self.get(
|
|
65
|
+
f"/api/v2/main/stations/by_id/{id}",
|
|
66
|
+
)
|
|
67
|
+
).json()
|
|
68
|
+
|
|
69
|
+
async def get_station_by_slug(self, slug: str) -> ExtendedStationInfo:
|
|
70
|
+
# https://koleo.pl/api/v2/main/stations/by_slug/inowroclaw
|
|
71
|
+
return (
|
|
72
|
+
await self.get(
|
|
73
|
+
f"/api/v2/main/stations/by_slug/{slug}",
|
|
74
|
+
)
|
|
75
|
+
).json()
|
|
76
|
+
|
|
77
|
+
async def get_station_info_by_slug(self, slug: str) -> StationDetails:
|
|
78
|
+
# https://koleo.pl/api/v2/main/station_info/inowroclaw
|
|
79
|
+
return (
|
|
80
|
+
await self.get(
|
|
81
|
+
f"/api/v2/main/station_info/{slug}",
|
|
82
|
+
)
|
|
83
|
+
).json()
|
|
84
|
+
|
|
85
|
+
async def get_departures(self, station_id: int, date: datetime) -> list[TrainOnStationInfo]:
|
|
86
|
+
# https://koleo.pl/api/v2/main/timetables/18705/2024-03-25/departures
|
|
87
|
+
return (
|
|
88
|
+
await self.get(
|
|
89
|
+
f"/api/v2/main/timetables/{station_id}/{date.strftime("%Y-%m-%d")}/departures",
|
|
90
|
+
)
|
|
91
|
+
).json()
|
|
92
|
+
|
|
93
|
+
async def get_arrivals(self, station_id: int, date: datetime) -> list[TrainOnStationInfo]:
|
|
94
|
+
# https://koleo.pl/api/v2/main/timetables/18705/2024-03-25/arrivals
|
|
95
|
+
return (
|
|
96
|
+
await self.get(
|
|
97
|
+
f"/api/v2/main/timetables/{station_id}/{date.strftime("%Y-%m-%d")}/arrivals",
|
|
98
|
+
)
|
|
99
|
+
).json()
|
|
100
|
+
|
|
101
|
+
async def get_train_calendars(self, brand_name: str, number: int, name: str | None = None) -> TrainCalendarResponse:
|
|
102
|
+
# https://koleo.pl/pl/train_calendars?brand=REG&nr=10417
|
|
103
|
+
# https://koleo.pl/pl/train_calendars?brand=IC&nr=1106&name=ESPERANTO ; WHY!!!! WHY!!!!!!1
|
|
104
|
+
params = {"brand": brand_name, "nr": number}
|
|
105
|
+
if name:
|
|
106
|
+
params["name"] = name.upper() # WHY!!!!!!!!!
|
|
107
|
+
return (await self.get("/pl/train_calendars", params=params)).json()
|
|
108
|
+
|
|
109
|
+
async def get_train(self, id: int) -> TrainDetailResponse:
|
|
110
|
+
# https://koleo.pl/pl/trains/142821312
|
|
111
|
+
return (await self.get(f"/pl/trains/{id}")).json()
|
|
112
|
+
|
|
113
|
+
async def get_connections(
|
|
114
|
+
self,
|
|
115
|
+
start: str,
|
|
116
|
+
end: str,
|
|
117
|
+
brand_ids: list[int],
|
|
118
|
+
date: datetime,
|
|
119
|
+
direct: bool = False,
|
|
120
|
+
purchasable: bool = False,
|
|
121
|
+
) -> list[ConnectionDetail]:
|
|
122
|
+
params = {
|
|
123
|
+
"query[date]": date.strftime("%d-%m-%Y %H:%M:%S"),
|
|
124
|
+
"query[start_station]": start,
|
|
125
|
+
"query[end_station]": end,
|
|
126
|
+
"query[only_purchasable]": str(purchasable).lower(),
|
|
127
|
+
"query[only_direct]": str(direct).lower(),
|
|
128
|
+
"query[brand_ids][]": brand_ids,
|
|
129
|
+
}
|
|
130
|
+
return (await self.get("/api/v2/main/connections", params=params)).json()["connections"]
|
|
131
|
+
|
|
132
|
+
async def get_connection(self, id: int) -> ConnectionDetail:
|
|
133
|
+
return (
|
|
134
|
+
await self.get(
|
|
135
|
+
f"/api/v2/main/connections/{id}",
|
|
136
|
+
)
|
|
137
|
+
).json()
|
|
138
|
+
|
|
139
|
+
async def get_brands(self) -> list[ApiBrand]:
|
|
140
|
+
# https://koleo.pl/api/v2/main/brands
|
|
141
|
+
return (
|
|
142
|
+
await self.get(
|
|
143
|
+
"/api/v2/main/brands",
|
|
144
|
+
)
|
|
145
|
+
).json()
|
|
146
|
+
|
|
147
|
+
async def get_carriers(self) -> list[Carrier]:
|
|
148
|
+
# https://koleo.pl/api/v2/main/carriers
|
|
149
|
+
return (
|
|
150
|
+
await self.get(
|
|
151
|
+
"/api/v2/main/carriers",
|
|
152
|
+
)
|
|
153
|
+
).json()
|
|
154
|
+
|
|
155
|
+
async def get_discounts(self) -> list[DiscountInfo]:
|
|
156
|
+
# https://koleo.pl/api/v2/main/discounts
|
|
157
|
+
return (
|
|
158
|
+
await self.get(
|
|
159
|
+
"/api/v2/main/discounts",
|
|
160
|
+
)
|
|
161
|
+
).json()
|
|
162
|
+
|
|
163
|
+
async def get_nested_train_place_types(self, connection_id: int) -> SeatsAvailabilityResponse:
|
|
164
|
+
# https://koleo.pl/api/v2/main/seats_availability/connection_id/train_nr/place_type
|
|
165
|
+
await self._require_auth()
|
|
166
|
+
if self._auth and "_koleo_token" not in self._auth:
|
|
167
|
+
res = await self.post(f"/prices/{connection_id}/passengers")
|
|
168
|
+
self._auth["_koleo_token"] = koleo_token = res.response.cookies["_koleo_token"].value
|
|
169
|
+
return (
|
|
170
|
+
await self.get(
|
|
171
|
+
f"/api/v2/main/nested_train_place_types/{connection_id}",
|
|
172
|
+
headers={"Authorization": f"Bearer {koleo_token}"},
|
|
173
|
+
use_auth=True,
|
|
174
|
+
)
|
|
175
|
+
).json()
|
|
176
|
+
|
|
177
|
+
async def get_seats_availability(
|
|
178
|
+
self, connection_id: int, train_nr: int, place_type: int
|
|
179
|
+
) -> SeatsAvailabilityResponse:
|
|
180
|
+
# https://koleo.pl/api/v2/main/seats_availability/connection_id/train_nr/place_type
|
|
181
|
+
return (
|
|
182
|
+
await self.get(
|
|
183
|
+
f"/api/v2/main/seats_availability/{connection_id}/{train_nr}/{place_type}",
|
|
184
|
+
)
|
|
185
|
+
).json()
|
|
186
|
+
|
|
187
|
+
async def get_train_composition(
|
|
188
|
+
self, connection_id: int, train_nr: int, place_type: int
|
|
189
|
+
) -> SeatsAvailabilityResponse:
|
|
190
|
+
# https://koleo.pl/api/v2/main/train_composition/connection_id/train_nr/place_type
|
|
191
|
+
return (
|
|
192
|
+
await self.get(
|
|
193
|
+
f"/api/v2/main/train_composition/{connection_id}/{train_nr}/{place_type}",
|
|
194
|
+
)
|
|
195
|
+
).json()
|
|
196
|
+
|
|
197
|
+
async def get_carriage_type(self, id: int) -> CarriageType:
|
|
198
|
+
# https://koleo.pl/api/v2/main/carriage_types/id
|
|
199
|
+
return (
|
|
200
|
+
await self.get(
|
|
201
|
+
f"/api/v2/main/carriage_types/{id}",
|
|
202
|
+
)
|
|
203
|
+
).json()
|
|
204
|
+
|
|
205
|
+
async def get_carriage_types(self) -> list[CarriageType]:
|
|
206
|
+
return (await self.get("/api/v2/main/carriage_types")).json()
|
|
207
|
+
|
|
208
|
+
async def get_station_keywoards(self) -> list[StationKeyword]:
|
|
209
|
+
return (await self.get("/api/v2/main/station_keywords")).json()
|
|
210
|
+
|
|
211
|
+
async def get_price(self, connection_id: int) -> Price | None:
|
|
212
|
+
res = await self.get(
|
|
213
|
+
f"/pl/prices/{connection_id}",
|
|
214
|
+
)
|
|
215
|
+
return res.json().get("price")
|
|
216
|
+
|
|
217
|
+
async def get_current_session(self) -> CurrentSession:
|
|
218
|
+
return (await self.get(f"/sessions/current", use_auth=True)).json()
|
|
219
|
+
|
|
220
|
+
async def get_current_user(self) -> CurrentUser:
|
|
221
|
+
return (await self.get(f"/users/current", use_auth=True)).json()
|
koleo/api/errors.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from aiohttp import ClientResponse, RequestInfo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class errors:
|
|
9
|
+
class KoleoAPIException(Exception):
|
|
10
|
+
status: int
|
|
11
|
+
request: "RequestInfo"
|
|
12
|
+
response: "ClientResponse"
|
|
13
|
+
|
|
14
|
+
def __init__(self, response: "ClientResponse", *args: object) -> None:
|
|
15
|
+
super().__init__(*args)
|
|
16
|
+
self.status = response.status
|
|
17
|
+
self.request = response.request_info
|
|
18
|
+
self.response = response
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
async def from_response(response: "ClientResponse") -> "KoleoAPIException":
|
|
22
|
+
if response.status == 404:
|
|
23
|
+
return errors.KoleoNotFound(response)
|
|
24
|
+
elif response.status == 401:
|
|
25
|
+
return errors.KoleoUnauthorized(response)
|
|
26
|
+
elif response.status == 403:
|
|
27
|
+
return errors.KoleoForbidden(response)
|
|
28
|
+
elif response.status == 429:
|
|
29
|
+
return errors.KoleoRatelimited(response)
|
|
30
|
+
else:
|
|
31
|
+
return errors.KoleoAPIException(response, await response.text())
|
|
32
|
+
|
|
33
|
+
class KoleoNotFound(KoleoAPIException):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
class KoleoForbidden(KoleoAPIException):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
class KoleoUnauthorized(KoleoAPIException):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
class KoleoRatelimited(KoleoAPIException):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
class AuthRequired(Exception):
|
|
46
|
+
pass
|
koleo/api/logging.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LoggingMixin:
|
|
5
|
+
_l: logging.Logger
|
|
6
|
+
_l_name: str | None = None
|
|
7
|
+
|
|
8
|
+
def __init__(self, name: str | None = None) -> None:
|
|
9
|
+
if name:
|
|
10
|
+
self._l_name = name
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def logger(self) -> logging.Logger:
|
|
14
|
+
if not getattr(self, "_l", None):
|
|
15
|
+
self._l = logging.getLogger(self.logger_name)
|
|
16
|
+
return self._l
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def logger_name(self) -> str:
|
|
20
|
+
return getattr(self, "_l_name", None) or self.__class__.__name__.lower()
|
|
21
|
+
|
|
22
|
+
def dl(self, msg, *args, **kwargs):
|
|
23
|
+
self.logger.debug(msg, *args, **kwargs)
|
|
24
|
+
|
|
25
|
+
def error(self, msg, *args, **kwargs):
|
|
26
|
+
self.logger.error(msg, *args, **kwargs)
|
|
27
|
+
|
|
28
|
+
def warn(self, msg, *args, **kwargs):
|
|
29
|
+
self.logger.warning(msg, *args, **kwargs)
|
|
30
|
+
|
|
31
|
+
def info(self, msg, *args, **kwargs):
|
|
32
|
+
self.logger.info(msg, *args, **kwargs)
|
|
33
|
+
|
|
34
|
+
def create_logging_context(self, prefix: str) -> "ContextLogger":
|
|
35
|
+
return ContextLogger(self.logger, prefix)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ContextLogger:
|
|
39
|
+
def __init__(self, logger: logging.Logger, prefix: str) -> None:
|
|
40
|
+
self.logger = logger
|
|
41
|
+
self.prefix = prefix
|
|
42
|
+
|
|
43
|
+
def _make_msg(self, msg: str):
|
|
44
|
+
return f"{self.prefix}: {msg}"
|
|
45
|
+
|
|
46
|
+
def dl(self, msg, *args, **kwargs):
|
|
47
|
+
self.logger.debug(self._make_msg(msg), *args, **kwargs)
|
|
48
|
+
|
|
49
|
+
def info(self, msg, *args, **kwargs):
|
|
50
|
+
self.logger.info(self._make_msg(msg), *args, **kwargs)
|
|
51
|
+
|
|
52
|
+
def error(self, msg, *args, **kwargs):
|
|
53
|
+
self.logger.error(self._make_msg(msg), *args, **kwargs)
|
|
54
|
+
|
|
55
|
+
def warn(self, msg, *args, **kwargs):
|
|
56
|
+
self.logger.warning(self._make_msg(msg), *args, **kwargs)
|