koleo-cli 0.2.137.17__py3-none-any.whl → 0.2.137.19__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 CHANGED
@@ -1,2 +1,2 @@
1
1
  from .api import KoleoAPI
2
- from .types import *
2
+ from .api.types import *
koleo/__main__.py CHANGED
@@ -1,4 +1,4 @@
1
- from .cli import main
1
+ from .args import main
2
2
 
3
3
 
4
4
  if __name__ == "__main__":
koleo/api/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .client import KoleoAPI
2
+ from .types import *
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)