avia-api 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.
- avia_api/__init__.py +34 -0
- avia_api/_client.py +137 -0
- avia_api/_params.py +14 -0
- avia_api/_transport.py +167 -0
- avia_api/_utils.py +24 -0
- avia_api/exceptions.py +62 -0
- avia_api/models/__init__.py +41 -0
- avia_api/models/common.py +38 -0
- avia_api/models/directions.py +19 -0
- avia_api/models/prices.py +90 -0
- avia_api/models/reference.py +68 -0
- avia_api/py.typed +0 -0
- avia_api/resources/__init__.py +5 -0
- avia_api/resources/_base.py +18 -0
- avia_api/resources/directions.py +43 -0
- avia_api/resources/prices.py +249 -0
- avia_api/resources/reference.py +50 -0
- avia_api-0.1.0.dist-info/METADATA +172 -0
- avia_api-0.1.0.dist-info/RECORD +21 -0
- avia_api-0.1.0.dist-info/WHEEL +4 -0
- avia_api-0.1.0.dist-info/licenses/LICENSE +21 -0
avia_api/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Async Python client for the Travelpayouts / Aviasales Data API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from ._client import AviaApiClient
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
AviaApiAuthenticationError,
|
|
8
|
+
AviaApiConnectionError,
|
|
9
|
+
AviaApiError,
|
|
10
|
+
AviaApiHTTPStatusError,
|
|
11
|
+
AviaApiRateLimitError,
|
|
12
|
+
AviaApiResponseError,
|
|
13
|
+
AviaApiServerError,
|
|
14
|
+
AviaApiValidationError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Libraries should never configure handlers themselves - this only silences
|
|
18
|
+
# the "No handlers could be found" warning until the application attaches
|
|
19
|
+
# its own. See https://docs.python.org/3/howto/logging.html#library-config
|
|
20
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AviaApiClient",
|
|
26
|
+
"AviaApiError",
|
|
27
|
+
"AviaApiConnectionError",
|
|
28
|
+
"AviaApiHTTPStatusError",
|
|
29
|
+
"AviaApiAuthenticationError",
|
|
30
|
+
"AviaApiRateLimitError",
|
|
31
|
+
"AviaApiServerError",
|
|
32
|
+
"AviaApiResponseError",
|
|
33
|
+
"AviaApiValidationError",
|
|
34
|
+
]
|
avia_api/_client.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import TypeAdapter, ValidationError
|
|
10
|
+
from pyrate_limiter import Rate
|
|
11
|
+
|
|
12
|
+
from ._params import clean_params
|
|
13
|
+
from ._transport import build_transport
|
|
14
|
+
from ._utils import parse_retry_after
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
AviaApiAuthenticationError,
|
|
17
|
+
AviaApiConnectionError,
|
|
18
|
+
AviaApiHTTPStatusError,
|
|
19
|
+
AviaApiRateLimitError,
|
|
20
|
+
AviaApiResponseError,
|
|
21
|
+
AviaApiServerError,
|
|
22
|
+
AviaApiValidationError,
|
|
23
|
+
)
|
|
24
|
+
from .resources import DirectionsResource, PricesResource, ReferenceResource
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
DEFAULT_BASE_URL = "https://api.travelpayouts.com"
|
|
29
|
+
TOKEN_ENV_VAR = "TRAVELPAYOUTS_TOKEN"
|
|
30
|
+
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AviaApiClient:
|
|
35
|
+
"""Async client for the Travelpayouts / Aviasales Data API.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
async with AviaApiClient(token="...") as client:
|
|
39
|
+
prices = await client.prices.cheap(origin="MOW", destination="LED")
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
token: API token from the partner's Travelpayouts account. Falls
|
|
43
|
+
back to the ``TRAVELPAYOUTS_TOKEN`` environment variable. Most
|
|
44
|
+
endpoints work without one at a reduced quota, but reads always
|
|
45
|
+
attach it when available.
|
|
46
|
+
base_url: Overridable mainly for testing.
|
|
47
|
+
timeout: Passed straight to ``httpx``.
|
|
48
|
+
rate: A ``pyrate_limiter.Rate`` (or list of rates) capping outbound
|
|
49
|
+
request throughput. Defaults to 5 requests/second.
|
|
50
|
+
max_retries: Attempts for requests that fail with a connection error
|
|
51
|
+
or a 429/5xx status, with exponential backoff (honoring
|
|
52
|
+
``Retry-After`` when present).
|
|
53
|
+
cache_ttl: How long a successful GET response is reused for, in
|
|
54
|
+
seconds. ``None`` disables the cache.
|
|
55
|
+
cache_path: SQLite file backing the cache (relative paths land under
|
|
56
|
+
``.cache/hishel/``, matching hishel's own convention).
|
|
57
|
+
transport: Escape hatch for tests or advanced setups - supplying this
|
|
58
|
+
bypasses rate limiting/retries/caching entirely.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
token: str | None = None,
|
|
64
|
+
*,
|
|
65
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
66
|
+
timeout: float | httpx.Timeout = 10.0,
|
|
67
|
+
rate: Rate | list[Rate] | None = None,
|
|
68
|
+
max_retries: int = 3,
|
|
69
|
+
cache_ttl: float | None = 1800.0,
|
|
70
|
+
cache_path: str | Path = "avia_api.db",
|
|
71
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
token = token or os.environ.get(TOKEN_ENV_VAR)
|
|
74
|
+
headers = {"X-Access-Token": token} if token else {}
|
|
75
|
+
if transport is None:
|
|
76
|
+
transport = build_transport(
|
|
77
|
+
rate=rate,
|
|
78
|
+
max_retries=max_retries,
|
|
79
|
+
cache_ttl=cache_ttl,
|
|
80
|
+
cache_path=cache_path,
|
|
81
|
+
)
|
|
82
|
+
self._http = httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout, transport=transport)
|
|
83
|
+
|
|
84
|
+
self.prices = PricesResource(self)
|
|
85
|
+
self.directions = DirectionsResource(self)
|
|
86
|
+
self.reference = ReferenceResource(self)
|
|
87
|
+
|
|
88
|
+
async def __aenter__(self) -> AviaApiClient:
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
92
|
+
await self.aclose()
|
|
93
|
+
|
|
94
|
+
async def aclose(self) -> None:
|
|
95
|
+
await self._http.aclose()
|
|
96
|
+
|
|
97
|
+
async def _get_json(self, path: str, *, params: dict[str, Any], adapter: TypeAdapter[T]) -> T:
|
|
98
|
+
cleaned_params = clean_params(params)
|
|
99
|
+
logger.debug("GET %s params=%r", path, cleaned_params)
|
|
100
|
+
try:
|
|
101
|
+
response = await self._http.get(path, params=cleaned_params)
|
|
102
|
+
except httpx.TransportError as exc:
|
|
103
|
+
logger.error("GET %s failed: %s", path, exc)
|
|
104
|
+
raise AviaApiConnectionError(str(exc)) from exc
|
|
105
|
+
|
|
106
|
+
self._raise_for_status(response)
|
|
107
|
+
|
|
108
|
+
payload = response.json()
|
|
109
|
+
if isinstance(payload, dict) and payload.get("success") is False:
|
|
110
|
+
logger.warning("GET %s returned success=false: %s", path, payload.get("error"))
|
|
111
|
+
raise AviaApiResponseError(payload.get("error") or "Aviasales API returned an error", payload=payload)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
result = adapter.validate_python(payload)
|
|
115
|
+
except ValidationError as exc:
|
|
116
|
+
logger.error("GET %s response failed schema validation: %s", path, exc)
|
|
117
|
+
raise AviaApiValidationError(str(exc)) from exc
|
|
118
|
+
|
|
119
|
+
logger.debug("GET %s -> %d", path, response.status_code)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
124
|
+
if response.status_code < 400:
|
|
125
|
+
return
|
|
126
|
+
if response.status_code in (401, 403):
|
|
127
|
+
logger.warning("%s -> %d (authentication error)", response.request.url, response.status_code)
|
|
128
|
+
raise AviaApiAuthenticationError(response)
|
|
129
|
+
if response.status_code == 429:
|
|
130
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
131
|
+
logger.warning("%s -> 429 (rate limited); retry_after=%s", response.request.url, retry_after)
|
|
132
|
+
raise AviaApiRateLimitError(response, retry_after=retry_after)
|
|
133
|
+
if response.status_code >= 500:
|
|
134
|
+
logger.error("%s -> %d (server error)", response.request.url, response.status_code)
|
|
135
|
+
raise AviaApiServerError(response)
|
|
136
|
+
logger.warning("%s -> %d", response.request.url, response.status_code)
|
|
137
|
+
raise AviaApiHTTPStatusError(response)
|
avia_api/_params.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def clean_params(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
8
|
+
"""Drop ``None`` values for use as query params.
|
|
9
|
+
|
|
10
|
+
httpx serializes booleans as ``true``/``false`` on its own, but it has no
|
|
11
|
+
special handling for ``None`` - it would be sent as an empty string
|
|
12
|
+
otherwise - so that's filtered out here before the request is built.
|
|
13
|
+
"""
|
|
14
|
+
return {key: value for key, value in params.items() if value is not None}
|
avia_api/_transport.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import hishel
|
|
8
|
+
import httpx
|
|
9
|
+
import tenacity
|
|
10
|
+
from hishel.httpx import AsyncCacheTransport
|
|
11
|
+
from pyrate_limiter import Duration, Limiter, Rate
|
|
12
|
+
|
|
13
|
+
from ._utils import parse_retry_after
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
#: Conservative default: Travelpayouts does not publish a single documented
|
|
18
|
+
#: number for the Data API, so this stays well under the limits mentioned for
|
|
19
|
+
#: other methods rather than assume the account's actual quota.
|
|
20
|
+
DEFAULT_RATE = Rate(5, Duration.SECOND)
|
|
21
|
+
|
|
22
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
23
|
+
|
|
24
|
+
_EXPONENTIAL_WAIT = tenacity.wait_exponential_jitter(initial=0.5, max=8.0)
|
|
25
|
+
|
|
26
|
+
#: Only logged when the rate limiter actually delayed a request by more than
|
|
27
|
+
#: this many seconds - avoids a debug line on every single request.
|
|
28
|
+
_RATE_LIMIT_LOG_THRESHOLD = 0.01
|
|
29
|
+
|
|
30
|
+
#: Upper bound for a single retry wait derived from a server-supplied
|
|
31
|
+
#: ``Retry-After`` header. Without this, a large (or misconfigured) value -
|
|
32
|
+
#: Travelpayouts documents nothing here, and some APIs send minutes during
|
|
33
|
+
#: incidents - would stall a request for that long on every retried attempt,
|
|
34
|
+
#: since ``stop_after_attempt`` only bounds the number of attempts, not how
|
|
35
|
+
#: long each one is allowed to wait.
|
|
36
|
+
MAX_RETRY_AFTER_WAIT = 60.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _RetryableStatusError(Exception):
|
|
40
|
+
"""Internal signal: the response's status code should trigger a retry."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, retry_after: float | None, *, status_code: int | None = None) -> None:
|
|
43
|
+
self.retry_after = retry_after
|
|
44
|
+
self.status_code = status_code
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _wait(retry_state: tenacity.RetryCallState) -> float:
|
|
48
|
+
exc = retry_state.outcome.exception() if retry_state.outcome else None
|
|
49
|
+
if isinstance(exc, _RetryableStatusError) and exc.retry_after is not None:
|
|
50
|
+
if exc.retry_after > MAX_RETRY_AFTER_WAIT:
|
|
51
|
+
logger.warning(
|
|
52
|
+
"Server requested Retry-After=%.1fs; capping wait to %.1fs",
|
|
53
|
+
exc.retry_after,
|
|
54
|
+
MAX_RETRY_AFTER_WAIT,
|
|
55
|
+
)
|
|
56
|
+
return min(exc.retry_after, MAX_RETRY_AFTER_WAIT)
|
|
57
|
+
return _EXPONENTIAL_WAIT(retry_state)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _ResilientTransport(httpx.AsyncBaseTransport):
|
|
61
|
+
"""Applies client-side rate limiting and retries transient failures.
|
|
62
|
+
|
|
63
|
+
Sits directly on top of the network transport, underneath the cache
|
|
64
|
+
transport, so cache hits never touch the limiter or the retry loop.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
transport: httpx.AsyncBaseTransport,
|
|
70
|
+
*,
|
|
71
|
+
rate: Rate | list[Rate],
|
|
72
|
+
max_retries: int,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._transport = transport
|
|
75
|
+
self._limiter = Limiter(rate)
|
|
76
|
+
self._max_retries = max(max_retries, 1)
|
|
77
|
+
|
|
78
|
+
def _before_sleep(self, request: httpx.Request, retry_state: tenacity.RetryCallState) -> None:
|
|
79
|
+
exc = retry_state.outcome.exception() if retry_state.outcome else None
|
|
80
|
+
wait = retry_state.next_action.sleep if retry_state.next_action else 0.0
|
|
81
|
+
reason = f"HTTP {exc.status_code}" if isinstance(exc, _RetryableStatusError) else repr(exc)
|
|
82
|
+
logger.warning(
|
|
83
|
+
"%s %s failed on attempt %d/%d (%s); retrying in %.2fs",
|
|
84
|
+
request.method,
|
|
85
|
+
request.url,
|
|
86
|
+
retry_state.attempt_number,
|
|
87
|
+
self._max_retries,
|
|
88
|
+
reason,
|
|
89
|
+
wait,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
93
|
+
response: httpx.Response
|
|
94
|
+
retrying = tenacity.AsyncRetrying(
|
|
95
|
+
stop=tenacity.stop_after_attempt(self._max_retries),
|
|
96
|
+
wait=_wait,
|
|
97
|
+
retry=tenacity.retry_if_exception_type((httpx.TransportError, _RetryableStatusError)),
|
|
98
|
+
reraise=True,
|
|
99
|
+
before_sleep=lambda retry_state: self._before_sleep(request, retry_state),
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
async for attempt in retrying:
|
|
103
|
+
with attempt:
|
|
104
|
+
start = time.monotonic()
|
|
105
|
+
await self._limiter.try_acquire_async("avia-api")
|
|
106
|
+
delay = time.monotonic() - start
|
|
107
|
+
if delay > _RATE_LIMIT_LOG_THRESHOLD:
|
|
108
|
+
logger.debug("Rate limiter delayed %s %s by %.3fs", request.method, request.url, delay)
|
|
109
|
+
response = await self._transport.handle_async_request(request)
|
|
110
|
+
if response.status_code in RETRYABLE_STATUS_CODES:
|
|
111
|
+
# Read the body before closing so the caller can still
|
|
112
|
+
# inspect status/headers/content once retries run out.
|
|
113
|
+
await response.aread()
|
|
114
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
115
|
+
await response.aclose()
|
|
116
|
+
raise _RetryableStatusError(retry_after, status_code=response.status_code)
|
|
117
|
+
except _RetryableStatusError as exc:
|
|
118
|
+
# Retry budget exhausted on a bad status: hand the last response
|
|
119
|
+
# back to the caller instead of leaking this internal signal.
|
|
120
|
+
logger.warning(
|
|
121
|
+
"%s %s exhausted retry budget (%d attempt(s)); giving up with status %d",
|
|
122
|
+
request.method,
|
|
123
|
+
request.url,
|
|
124
|
+
self._max_retries,
|
|
125
|
+
exc.status_code,
|
|
126
|
+
)
|
|
127
|
+
return response
|
|
128
|
+
except httpx.TransportError:
|
|
129
|
+
logger.warning(
|
|
130
|
+
"%s %s exhausted retry budget (%d attempt(s)); giving up",
|
|
131
|
+
request.method,
|
|
132
|
+
request.url,
|
|
133
|
+
self._max_retries,
|
|
134
|
+
)
|
|
135
|
+
raise
|
|
136
|
+
logger.debug("%s %s -> %d", request.method, request.url, response.status_code)
|
|
137
|
+
return response
|
|
138
|
+
|
|
139
|
+
async def aclose(self) -> None:
|
|
140
|
+
await self._transport.aclose()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_transport(
|
|
144
|
+
*,
|
|
145
|
+
rate: Rate | list[Rate] | None,
|
|
146
|
+
max_retries: int,
|
|
147
|
+
cache_ttl: float | None,
|
|
148
|
+
cache_path: str | Path,
|
|
149
|
+
) -> httpx.AsyncBaseTransport:
|
|
150
|
+
"""Build the layered transport: cache -> rate limit/retry -> network.
|
|
151
|
+
|
|
152
|
+
``cache_ttl=None`` disables caching entirely, leaving just the resilient
|
|
153
|
+
transport in place.
|
|
154
|
+
"""
|
|
155
|
+
network = httpx.AsyncHTTPTransport(retries=0)
|
|
156
|
+
resilient: httpx.AsyncBaseTransport = _ResilientTransport(
|
|
157
|
+
network, rate=rate or DEFAULT_RATE, max_retries=max_retries
|
|
158
|
+
)
|
|
159
|
+
if cache_ttl is None:
|
|
160
|
+
return resilient
|
|
161
|
+
|
|
162
|
+
# Travelpayouts responses carry no Cache-Control/ETag headers, so a
|
|
163
|
+
# spec-compliant (RFC 9111) cache policy would never store anything.
|
|
164
|
+
# FilterPolicy with no filters caches every response unconditionally,
|
|
165
|
+
# and the storage's own default_ttl governs expiry instead.
|
|
166
|
+
storage = hishel.AsyncSqliteStorage(database_path=cache_path, default_ttl=cache_ttl)
|
|
167
|
+
return AsyncCacheTransport(next_transport=resilient, storage=storage, policy=hishel.FilterPolicy())
|
avia_api/_utils.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from email.utils import parsedate_to_datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
8
|
+
"""Parse a ``Retry-After`` header into a number of seconds.
|
|
9
|
+
|
|
10
|
+
Supports both the delay-seconds and HTTP-date forms defined by RFC 9110.
|
|
11
|
+
Returns ``None`` if the header is missing or unparsable.
|
|
12
|
+
"""
|
|
13
|
+
if not value:
|
|
14
|
+
return None
|
|
15
|
+
value = value.strip()
|
|
16
|
+
if value.isdigit():
|
|
17
|
+
return float(value)
|
|
18
|
+
try:
|
|
19
|
+
retry_at = parsedate_to_datetime(value)
|
|
20
|
+
except (TypeError, ValueError):
|
|
21
|
+
return None
|
|
22
|
+
if retry_at.tzinfo is None:
|
|
23
|
+
return None
|
|
24
|
+
return max((retry_at - datetime.now(retry_at.tzinfo)).total_seconds(), 0.0)
|
avia_api/exceptions.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AviaApiError(Exception):
|
|
9
|
+
"""Base class for every error raised by this library."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AviaApiConnectionError(AviaApiError):
|
|
13
|
+
"""The request could not be sent, or no response was received.
|
|
14
|
+
|
|
15
|
+
Raised after the underlying network error survived all retry attempts.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AviaApiHTTPStatusError(AviaApiError):
|
|
20
|
+
"""The server responded with an HTTP error status.
|
|
21
|
+
|
|
22
|
+
This is only raised for statuses that are not retried automatically
|
|
23
|
+
(or that survived every retry attempt).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
27
|
+
self.response = response
|
|
28
|
+
self.status_code = response.status_code
|
|
29
|
+
super().__init__(f"{response.status_code} {response.reason_phrase} for url {response.request.url}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AviaApiAuthenticationError(AviaApiHTTPStatusError):
|
|
33
|
+
"""Raised on HTTP 401/403 - the API token is missing or invalid."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AviaApiRateLimitError(AviaApiHTTPStatusError):
|
|
37
|
+
"""Raised on HTTP 429 after the retry budget was exhausted."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, response: httpx.Response, *, retry_after: float | None) -> None:
|
|
40
|
+
super().__init__(response)
|
|
41
|
+
self.retry_after = retry_after
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AviaApiServerError(AviaApiHTTPStatusError):
|
|
45
|
+
"""Raised on HTTP 5xx after the retry budget was exhausted."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AviaApiResponseError(AviaApiError):
|
|
49
|
+
"""The API responded with HTTP 200 but ``{"success": false}`` in the body."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, *, payload: Any = None) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.payload = payload
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AviaApiValidationError(AviaApiError):
|
|
57
|
+
"""The response body did not match the expected schema.
|
|
58
|
+
|
|
59
|
+
This usually means the API changed shape; it is kept distinct from
|
|
60
|
+
transport-level errors so callers can tell "we got a bad response" apart
|
|
61
|
+
from "we couldn't reach the server".
|
|
62
|
+
"""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from .common import Envelope
|
|
2
|
+
from .directions import CityDirectionPrice
|
|
3
|
+
from .prices import (
|
|
4
|
+
CalendarPrice,
|
|
5
|
+
LatestPriceEntry,
|
|
6
|
+
MatrixPriceEntry,
|
|
7
|
+
MonthlyPrice,
|
|
8
|
+
NearestPlacesMatrixPrice,
|
|
9
|
+
NearestPlacesMatrixResponse,
|
|
10
|
+
SimplePrice,
|
|
11
|
+
)
|
|
12
|
+
from .reference import (
|
|
13
|
+
Airline,
|
|
14
|
+
AirlineAlliance,
|
|
15
|
+
Airport,
|
|
16
|
+
City,
|
|
17
|
+
Coordinates,
|
|
18
|
+
Country,
|
|
19
|
+
Plane,
|
|
20
|
+
Route,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Envelope",
|
|
25
|
+
"SimplePrice",
|
|
26
|
+
"CalendarPrice",
|
|
27
|
+
"MonthlyPrice",
|
|
28
|
+
"LatestPriceEntry",
|
|
29
|
+
"MatrixPriceEntry",
|
|
30
|
+
"NearestPlacesMatrixPrice",
|
|
31
|
+
"NearestPlacesMatrixResponse",
|
|
32
|
+
"CityDirectionPrice",
|
|
33
|
+
"Coordinates",
|
|
34
|
+
"Country",
|
|
35
|
+
"City",
|
|
36
|
+
"Airport",
|
|
37
|
+
"Airline",
|
|
38
|
+
"AirlineAlliance",
|
|
39
|
+
"Plane",
|
|
40
|
+
"Route",
|
|
41
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from typing import Annotated, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _empty_str_to_none(value: object) -> object:
|
|
12
|
+
return None if value == "" else value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
#: Several endpoints use ``""`` instead of omitting the field for a one-way
|
|
16
|
+
#: trip's missing return date/datetime.
|
|
17
|
+
OptionalDate = Annotated[date | None, BeforeValidator(_empty_str_to_none)]
|
|
18
|
+
OptionalDateTime = Annotated[datetime | None, BeforeValidator(_empty_str_to_none)]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AviaBaseModel(BaseModel):
|
|
22
|
+
"""Base for all response models.
|
|
23
|
+
|
|
24
|
+
``extra="allow"`` means fields the API adds later show up on
|
|
25
|
+
``model_extra`` instead of raising, so a new, undocumented field never
|
|
26
|
+
breaks parsing of the fields we do know about.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="allow", frozen=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Envelope(AviaBaseModel, Generic[T]):
|
|
33
|
+
"""The ``{"success", "data", "error"}`` shape shared by most v1/v2 endpoints."""
|
|
34
|
+
|
|
35
|
+
success: bool
|
|
36
|
+
data: T
|
|
37
|
+
error: str | None = None
|
|
38
|
+
currency: str | None = None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from .common import AviaBaseModel, OptionalDateTime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CityDirectionPrice(AviaBaseModel):
|
|
9
|
+
"""An entry from ``/v1/city-directions``."""
|
|
10
|
+
|
|
11
|
+
origin: str
|
|
12
|
+
destination: str
|
|
13
|
+
price: int
|
|
14
|
+
transfers: int
|
|
15
|
+
airline: str
|
|
16
|
+
flight_number: int
|
|
17
|
+
departure_at: datetime
|
|
18
|
+
return_at: OptionalDateTime = None
|
|
19
|
+
expires_at: datetime
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from .common import AviaBaseModel, OptionalDate, OptionalDateTime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SimplePrice(AviaBaseModel):
|
|
11
|
+
"""An entry from ``/v1/prices/cheap`` or ``/v1/prices/direct``."""
|
|
12
|
+
|
|
13
|
+
price: int
|
|
14
|
+
airline: str
|
|
15
|
+
flight_number: int
|
|
16
|
+
departure_at: datetime
|
|
17
|
+
return_at: OptionalDateTime = None
|
|
18
|
+
expires_at: datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CalendarPrice(AviaBaseModel):
|
|
22
|
+
"""An entry from ``/v1/prices/calendar`` (and reused for city-directions)."""
|
|
23
|
+
|
|
24
|
+
origin: str
|
|
25
|
+
destination: str
|
|
26
|
+
price: int
|
|
27
|
+
transfers: int
|
|
28
|
+
airline: str
|
|
29
|
+
flight_number: int
|
|
30
|
+
departure_at: datetime
|
|
31
|
+
return_at: OptionalDateTime = None
|
|
32
|
+
expires_at: datetime
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MonthlyPrice(CalendarPrice):
|
|
36
|
+
"""An entry from ``/v1/prices/monthly``. Same shape as :class:`CalendarPrice`."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LatestPriceEntry(AviaBaseModel):
|
|
40
|
+
"""An entry from ``/v2/prices/latest``."""
|
|
41
|
+
|
|
42
|
+
show_to_affiliates: bool
|
|
43
|
+
trip_class: int
|
|
44
|
+
origin: str
|
|
45
|
+
destination: str
|
|
46
|
+
depart_date: OptionalDate = None
|
|
47
|
+
return_date: OptionalDate = None
|
|
48
|
+
number_of_changes: int
|
|
49
|
+
value: float
|
|
50
|
+
found_at: datetime
|
|
51
|
+
distance: int | None = None
|
|
52
|
+
actual: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MatrixPriceEntry(LatestPriceEntry):
|
|
56
|
+
"""An entry from ``/v2/prices/month-matrix`` or ``/v2/prices/week-matrix``.
|
|
57
|
+
|
|
58
|
+
Same shape as :class:`LatestPriceEntry`.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class NearestPlacesMatrixPrice(AviaBaseModel):
|
|
63
|
+
"""A price entry from ``/v2/prices/nearest-places-matrix``."""
|
|
64
|
+
|
|
65
|
+
value: float
|
|
66
|
+
trip_class: int
|
|
67
|
+
show_to_affiliates: bool
|
|
68
|
+
origin: str
|
|
69
|
+
destination: str
|
|
70
|
+
depart_date: OptionalDate = None
|
|
71
|
+
return_date: OptionalDate = None
|
|
72
|
+
number_of_changes: int
|
|
73
|
+
gate: str | None = None
|
|
74
|
+
found_at: datetime
|
|
75
|
+
duration: int | None = None
|
|
76
|
+
distance: int | None = None
|
|
77
|
+
actual: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class NearestPlacesMatrixResponse(AviaBaseModel):
|
|
81
|
+
"""The body of ``/v2/prices/nearest-places-matrix``.
|
|
82
|
+
|
|
83
|
+
Unlike the other v1/v2 endpoints, this one is not wrapped in an
|
|
84
|
+
``Envelope`` - it has its own top-level shape.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
prices: list[NearestPlacesMatrixPrice] = Field(default_factory=list)
|
|
88
|
+
origins: list[str] = Field(default_factory=list)
|
|
89
|
+
destinations: list[str] = Field(default_factory=list)
|
|
90
|
+
errors: dict[str, object] = Field(default_factory=dict)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from .common import AviaBaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Coordinates(AviaBaseModel):
|
|
9
|
+
lon: float
|
|
10
|
+
lat: float
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Country(AviaBaseModel):
|
|
14
|
+
code: str
|
|
15
|
+
name: str
|
|
16
|
+
currency: str | None = None
|
|
17
|
+
name_translations: dict[str, str] = Field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class City(AviaBaseModel):
|
|
21
|
+
code: str
|
|
22
|
+
name: str
|
|
23
|
+
coordinates: Coordinates | None = None
|
|
24
|
+
time_zone: str | None = None
|
|
25
|
+
name_translations: dict[str, str] = Field(default_factory=dict)
|
|
26
|
+
country_code: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Airport(AviaBaseModel):
|
|
30
|
+
code: str
|
|
31
|
+
name: str
|
|
32
|
+
coordinates: Coordinates | None = None
|
|
33
|
+
time_zone: str | None = None
|
|
34
|
+
name_translations: dict[str, str] = Field(default_factory=dict)
|
|
35
|
+
country_code: str | None = None
|
|
36
|
+
city_code: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Airline(AviaBaseModel):
|
|
40
|
+
name: str
|
|
41
|
+
alias: str | None = None
|
|
42
|
+
iata: str | None = None
|
|
43
|
+
icao: str | None = None
|
|
44
|
+
callsign: str | None = None
|
|
45
|
+
country: str | None = None
|
|
46
|
+
is_active: bool = True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AirlineAlliance(AviaBaseModel):
|
|
50
|
+
name: str
|
|
51
|
+
airlines: list[str] = Field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Plane(AviaBaseModel):
|
|
55
|
+
code: str
|
|
56
|
+
name: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Route(AviaBaseModel):
|
|
60
|
+
airline_iata: str | None = None
|
|
61
|
+
airline_icao: str | None = None
|
|
62
|
+
departure_airport_iata: str | None = None
|
|
63
|
+
departure_airport_icao: str | None = None
|
|
64
|
+
arrival_airport_iata: str | None = None
|
|
65
|
+
arrival_airport_icao: str | None = None
|
|
66
|
+
codeshare: bool = False
|
|
67
|
+
transfers: int = 0
|
|
68
|
+
planes: list[str] = Field(default_factory=list)
|
avia_api/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
from pydantic import TypeAdapter
|
|
6
|
+
|
|
7
|
+
if t.TYPE_CHECKING:
|
|
8
|
+
from .._client import AviaApiClient
|
|
9
|
+
|
|
10
|
+
T = t.TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseResource:
|
|
14
|
+
def __init__(self, client: AviaApiClient) -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
async def _get(self, path: str, params: dict[str, t.Any], adapter: TypeAdapter[T]) -> T:
|
|
18
|
+
return await self._client._get_json(path, params=params, adapter=adapter)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import TypeAdapter
|
|
4
|
+
|
|
5
|
+
from .._params import clean_params
|
|
6
|
+
from ..models.common import Envelope
|
|
7
|
+
from ..models.directions import CityDirectionPrice
|
|
8
|
+
from ._base import BaseResource
|
|
9
|
+
|
|
10
|
+
_AIRLINE_ADAPTER = TypeAdapter(Envelope[dict[str, int]])
|
|
11
|
+
_CITY_ADAPTER = TypeAdapter(Envelope[dict[str, CityDirectionPrice]])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DirectionsResource(BaseResource):
|
|
15
|
+
"""Popular routes: ``/v1/airline-directions`` and ``/v1/city-directions``."""
|
|
16
|
+
|
|
17
|
+
async def airline(
|
|
18
|
+
self,
|
|
19
|
+
airline_code: str,
|
|
20
|
+
*,
|
|
21
|
+
limit: int | None = None,
|
|
22
|
+
) -> dict[str, int]:
|
|
23
|
+
"""Popular routes for one airline, mapped to their cheapest price.
|
|
24
|
+
|
|
25
|
+
``GET /v1/airline-directions``
|
|
26
|
+
"""
|
|
27
|
+
params = clean_params(dict(airline_code=airline_code, limit=limit))
|
|
28
|
+
envelope = await self._get("/v1/airline-directions", params, _AIRLINE_ADAPTER)
|
|
29
|
+
return envelope.data
|
|
30
|
+
|
|
31
|
+
async def city(
|
|
32
|
+
self,
|
|
33
|
+
origin: str,
|
|
34
|
+
*,
|
|
35
|
+
currency: str | None = None,
|
|
36
|
+
) -> dict[str, CityDirectionPrice]:
|
|
37
|
+
"""Popular destinations from one city, with the cheapest ticket found.
|
|
38
|
+
|
|
39
|
+
``GET /v1/city-directions``
|
|
40
|
+
"""
|
|
41
|
+
params = clean_params(dict(origin=origin, currency=currency))
|
|
42
|
+
envelope = await self._get("/v1/city-directions", params, _CITY_ADAPTER)
|
|
43
|
+
return envelope.data
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import TypeAdapter
|
|
4
|
+
|
|
5
|
+
from .._params import clean_params
|
|
6
|
+
from ..models.common import Envelope
|
|
7
|
+
from ..models.prices import (
|
|
8
|
+
CalendarPrice,
|
|
9
|
+
LatestPriceEntry,
|
|
10
|
+
MatrixPriceEntry,
|
|
11
|
+
MonthlyPrice,
|
|
12
|
+
NearestPlacesMatrixResponse,
|
|
13
|
+
SimplePrice,
|
|
14
|
+
)
|
|
15
|
+
from ._base import BaseResource
|
|
16
|
+
|
|
17
|
+
_CHEAP_ADAPTER = TypeAdapter(Envelope[dict[str, dict[str, SimplePrice]]])
|
|
18
|
+
_CALENDAR_ADAPTER = TypeAdapter(Envelope[dict[str, CalendarPrice]])
|
|
19
|
+
_MONTHLY_ADAPTER = TypeAdapter(Envelope[dict[str, MonthlyPrice]])
|
|
20
|
+
_LATEST_ADAPTER = TypeAdapter(Envelope[list[LatestPriceEntry]])
|
|
21
|
+
_MATRIX_ADAPTER = TypeAdapter(Envelope[list[MatrixPriceEntry]])
|
|
22
|
+
_NEAREST_PLACES_ADAPTER = TypeAdapter(NearestPlacesMatrixResponse)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PricesResource(BaseResource):
|
|
26
|
+
"""Cached ticket prices: ``/v1/prices/*`` and ``/v2/prices/*``.
|
|
27
|
+
|
|
28
|
+
Date-like parameters are plain strings - the API accepts either
|
|
29
|
+
``"YYYY-MM-DD"`` or, on some endpoints, a month-only ``"YYYY-MM"``, and
|
|
30
|
+
:class:`datetime.date` can't represent the latter, so the choice of
|
|
31
|
+
format is left to the caller instead of guessing at a conversion.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
async def cheap(
|
|
35
|
+
self,
|
|
36
|
+
origin: str,
|
|
37
|
+
destination: str,
|
|
38
|
+
*,
|
|
39
|
+
depart_date: str | None = None,
|
|
40
|
+
return_date: str | None = None,
|
|
41
|
+
currency: str | None = None,
|
|
42
|
+
page: int | None = None,
|
|
43
|
+
) -> dict[str, dict[str, SimplePrice]]:
|
|
44
|
+
"""The cheapest tickets found for each destination, grouped by index.
|
|
45
|
+
|
|
46
|
+
``GET /v1/prices/cheap``. ``depart_date``/``return_date``: ``"YYYY-MM"`` or ``"YYYY-MM-DD"``.
|
|
47
|
+
"""
|
|
48
|
+
params = clean_params(
|
|
49
|
+
dict(
|
|
50
|
+
origin=origin,
|
|
51
|
+
destination=destination,
|
|
52
|
+
depart_date=depart_date,
|
|
53
|
+
return_date=return_date,
|
|
54
|
+
currency=currency,
|
|
55
|
+
page=page,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
envelope = await self._get("/v1/prices/cheap", params, _CHEAP_ADAPTER)
|
|
59
|
+
return envelope.data
|
|
60
|
+
|
|
61
|
+
async def direct(
|
|
62
|
+
self,
|
|
63
|
+
origin: str,
|
|
64
|
+
destination: str,
|
|
65
|
+
*,
|
|
66
|
+
depart_date: str | None = None,
|
|
67
|
+
return_date: str | None = None,
|
|
68
|
+
currency: str | None = None,
|
|
69
|
+
page: int | None = None,
|
|
70
|
+
) -> dict[str, dict[str, SimplePrice]]:
|
|
71
|
+
"""The cheapest non-stop tickets found for each destination.
|
|
72
|
+
|
|
73
|
+
``GET /v1/prices/direct``. ``depart_date``/``return_date``: ``"YYYY-MM"`` or ``"YYYY-MM-DD"``.
|
|
74
|
+
"""
|
|
75
|
+
params = clean_params(
|
|
76
|
+
dict(
|
|
77
|
+
origin=origin,
|
|
78
|
+
destination=destination,
|
|
79
|
+
depart_date=depart_date,
|
|
80
|
+
return_date=return_date,
|
|
81
|
+
currency=currency,
|
|
82
|
+
page=page,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
envelope = await self._get("/v1/prices/direct", params, _CHEAP_ADAPTER)
|
|
86
|
+
return envelope.data
|
|
87
|
+
|
|
88
|
+
async def calendar(
|
|
89
|
+
self,
|
|
90
|
+
origin: str,
|
|
91
|
+
destination: str,
|
|
92
|
+
depart_date: str,
|
|
93
|
+
*,
|
|
94
|
+
calendar_type: str = "departure_date",
|
|
95
|
+
return_date: str | None = None,
|
|
96
|
+
length: int | None = None,
|
|
97
|
+
currency: str | None = None,
|
|
98
|
+
) -> dict[str, CalendarPrice]:
|
|
99
|
+
"""The cheapest ticket for each day of the month around ``depart_date``.
|
|
100
|
+
|
|
101
|
+
``GET /v1/prices/calendar``. ``depart_date``/``return_date``: ``"YYYY-MM"`` or ``"YYYY-MM-DD"``.
|
|
102
|
+
"""
|
|
103
|
+
params = clean_params(
|
|
104
|
+
dict(
|
|
105
|
+
origin=origin,
|
|
106
|
+
destination=destination,
|
|
107
|
+
depart_date=depart_date,
|
|
108
|
+
calendar_type=calendar_type,
|
|
109
|
+
return_date=return_date,
|
|
110
|
+
length=length,
|
|
111
|
+
currency=currency,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
envelope = await self._get("/v1/prices/calendar", params, _CALENDAR_ADAPTER)
|
|
115
|
+
return envelope.data
|
|
116
|
+
|
|
117
|
+
async def monthly(
|
|
118
|
+
self,
|
|
119
|
+
origin: str,
|
|
120
|
+
destination: str,
|
|
121
|
+
*,
|
|
122
|
+
currency: str | None = None,
|
|
123
|
+
) -> dict[str, MonthlyPrice]:
|
|
124
|
+
"""The cheapest ticket for each of the next several months.
|
|
125
|
+
|
|
126
|
+
``GET /v1/prices/monthly``
|
|
127
|
+
"""
|
|
128
|
+
params = clean_params(dict(origin=origin, destination=destination, currency=currency))
|
|
129
|
+
envelope = await self._get("/v1/prices/monthly", params, _MONTHLY_ADAPTER)
|
|
130
|
+
return envelope.data
|
|
131
|
+
|
|
132
|
+
async def latest(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
currency: str | None = None,
|
|
136
|
+
origin: str | None = None,
|
|
137
|
+
destination: str | None = None,
|
|
138
|
+
beginning_of_period: str | None = None,
|
|
139
|
+
period_type: str | None = None,
|
|
140
|
+
one_way: bool | None = None,
|
|
141
|
+
page: int | None = None,
|
|
142
|
+
limit: int | None = None,
|
|
143
|
+
show_to_affiliates: bool | None = None,
|
|
144
|
+
sorting: str | None = None,
|
|
145
|
+
) -> list[LatestPriceEntry]:
|
|
146
|
+
"""The most recently found prices across all of Aviasales' search history.
|
|
147
|
+
|
|
148
|
+
``GET /v2/prices/latest``. ``beginning_of_period``: ``"YYYY-MM-DD"``.
|
|
149
|
+
"""
|
|
150
|
+
params = clean_params(
|
|
151
|
+
dict(
|
|
152
|
+
currency=currency,
|
|
153
|
+
origin=origin,
|
|
154
|
+
destination=destination,
|
|
155
|
+
beginning_of_period=beginning_of_period,
|
|
156
|
+
period_type=period_type,
|
|
157
|
+
one_way=one_way,
|
|
158
|
+
page=page,
|
|
159
|
+
limit=limit,
|
|
160
|
+
show_to_affiliates=show_to_affiliates,
|
|
161
|
+
sorting=sorting,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
envelope = await self._get("/v2/prices/latest", params, _LATEST_ADAPTER)
|
|
165
|
+
return envelope.data
|
|
166
|
+
|
|
167
|
+
async def month_matrix(
|
|
168
|
+
self,
|
|
169
|
+
origin: str,
|
|
170
|
+
destination: str,
|
|
171
|
+
*,
|
|
172
|
+
currency: str | None = None,
|
|
173
|
+
month: str | None = None,
|
|
174
|
+
show_to_affiliates: bool | None = None,
|
|
175
|
+
) -> list[MatrixPriceEntry]:
|
|
176
|
+
"""A price calendar for every day of one month.
|
|
177
|
+
|
|
178
|
+
``GET /v2/prices/month-matrix``. ``month``: ``"YYYY-MM-DD"``.
|
|
179
|
+
"""
|
|
180
|
+
params = clean_params(
|
|
181
|
+
dict(
|
|
182
|
+
origin=origin,
|
|
183
|
+
destination=destination,
|
|
184
|
+
currency=currency,
|
|
185
|
+
month=month,
|
|
186
|
+
show_to_affiliates=show_to_affiliates,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
envelope = await self._get("/v2/prices/month-matrix", params, _MATRIX_ADAPTER)
|
|
190
|
+
return envelope.data
|
|
191
|
+
|
|
192
|
+
async def week_matrix(
|
|
193
|
+
self,
|
|
194
|
+
origin: str,
|
|
195
|
+
destination: str,
|
|
196
|
+
*,
|
|
197
|
+
currency: str | None = None,
|
|
198
|
+
depart_date: str | None = None,
|
|
199
|
+
return_date: str | None = None,
|
|
200
|
+
show_to_affiliates: bool | None = None,
|
|
201
|
+
) -> list[MatrixPriceEntry]:
|
|
202
|
+
"""A price calendar for the week around ``depart_date``.
|
|
203
|
+
|
|
204
|
+
``GET /v2/prices/week-matrix``. ``depart_date``/``return_date``: ``"YYYY-MM"`` or ``"YYYY-MM-DD"``.
|
|
205
|
+
"""
|
|
206
|
+
params = clean_params(
|
|
207
|
+
dict(
|
|
208
|
+
origin=origin,
|
|
209
|
+
destination=destination,
|
|
210
|
+
currency=currency,
|
|
211
|
+
depart_date=depart_date,
|
|
212
|
+
return_date=return_date,
|
|
213
|
+
show_to_affiliates=show_to_affiliates,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
envelope = await self._get("/v2/prices/week-matrix", params, _MATRIX_ADAPTER)
|
|
217
|
+
return envelope.data
|
|
218
|
+
|
|
219
|
+
async def nearest_places_matrix(
|
|
220
|
+
self,
|
|
221
|
+
origin: str,
|
|
222
|
+
destination: str,
|
|
223
|
+
*,
|
|
224
|
+
currency: str | None = None,
|
|
225
|
+
limit: int | None = None,
|
|
226
|
+
show_to_affiliates: bool | None = None,
|
|
227
|
+
depart_date: str | None = None,
|
|
228
|
+
return_date: str | None = None,
|
|
229
|
+
flexibility: int | None = None,
|
|
230
|
+
distance: int | None = None,
|
|
231
|
+
) -> NearestPlacesMatrixResponse:
|
|
232
|
+
"""Prices for nearby origin/destination airports, when the exact route is expensive.
|
|
233
|
+
|
|
234
|
+
``GET /v2/prices/nearest-places-matrix``. ``depart_date``/``return_date``: ``"YYYY-MM"`` or ``"YYYY-MM-DD"``.
|
|
235
|
+
"""
|
|
236
|
+
params = clean_params(
|
|
237
|
+
dict(
|
|
238
|
+
origin=origin,
|
|
239
|
+
destination=destination,
|
|
240
|
+
currency=currency,
|
|
241
|
+
limit=limit,
|
|
242
|
+
show_to_affiliates=show_to_affiliates,
|
|
243
|
+
depart_date=depart_date,
|
|
244
|
+
return_date=return_date,
|
|
245
|
+
flexibility=flexibility,
|
|
246
|
+
distance=distance,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
return await self._get("/v2/prices/nearest-places-matrix", params, _NEAREST_PLACES_ADAPTER)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import TypeAdapter
|
|
4
|
+
|
|
5
|
+
from ..models.reference import Airline, AirlineAlliance, Airport, City, Country, Plane, Route
|
|
6
|
+
from ._base import BaseResource
|
|
7
|
+
|
|
8
|
+
_COUNTRIES_ADAPTER = TypeAdapter(list[Country])
|
|
9
|
+
_CITIES_ADAPTER = TypeAdapter(list[City])
|
|
10
|
+
_AIRPORTS_ADAPTER = TypeAdapter(list[Airport])
|
|
11
|
+
_AIRLINES_ADAPTER = TypeAdapter(list[Airline])
|
|
12
|
+
_ALLIANCES_ADAPTER = TypeAdapter(list[AirlineAlliance])
|
|
13
|
+
_PLANES_ADAPTER = TypeAdapter(list[Plane])
|
|
14
|
+
_ROUTES_ADAPTER = TypeAdapter(list[Route])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReferenceResource(BaseResource):
|
|
18
|
+
"""Static reference data: ``/data/*.json``.
|
|
19
|
+
|
|
20
|
+
These are plain, unauthenticated JSON files that change rarely, so they
|
|
21
|
+
benefit the most from the client's response cache.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
async def countries(self, *, language: str = "en") -> list[Country]:
|
|
25
|
+
"""``GET /data/{language}/countries.json``"""
|
|
26
|
+
return await self._get(f"/data/{language}/countries.json", {}, _COUNTRIES_ADAPTER)
|
|
27
|
+
|
|
28
|
+
async def cities(self, *, language: str = "en") -> list[City]:
|
|
29
|
+
"""``GET /data/{language}/cities.json``"""
|
|
30
|
+
return await self._get(f"/data/{language}/cities.json", {}, _CITIES_ADAPTER)
|
|
31
|
+
|
|
32
|
+
async def airports(self, *, language: str = "en") -> list[Airport]:
|
|
33
|
+
"""``GET /data/{language}/airports.json``"""
|
|
34
|
+
return await self._get(f"/data/{language}/airports.json", {}, _AIRPORTS_ADAPTER)
|
|
35
|
+
|
|
36
|
+
async def airlines(self, *, language: str = "en") -> list[Airline]:
|
|
37
|
+
"""``GET /data/{language}/airlines.json``"""
|
|
38
|
+
return await self._get(f"/data/{language}/airlines.json", {}, _AIRLINES_ADAPTER)
|
|
39
|
+
|
|
40
|
+
async def airline_alliances(self, *, language: str = "en") -> list[AirlineAlliance]:
|
|
41
|
+
"""``GET /data/{language}/airlines_alliances.json``"""
|
|
42
|
+
return await self._get(f"/data/{language}/airlines_alliances.json", {}, _ALLIANCES_ADAPTER)
|
|
43
|
+
|
|
44
|
+
async def planes(self) -> list[Plane]:
|
|
45
|
+
"""``GET /data/planes.json``"""
|
|
46
|
+
return await self._get("/data/planes.json", {}, _PLANES_ADAPTER)
|
|
47
|
+
|
|
48
|
+
async def routes(self) -> list[Route]:
|
|
49
|
+
"""``GET /data/routes.json``"""
|
|
50
|
+
return await self._get("/data/routes.json", {}, _ROUTES_ADAPTER)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: avia-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for the Travelpayouts / Aviasales Data API
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: travelpayouts,aviasales,flights,airline-tickets,prices,api-client,async,httpx,pydantic
|
|
8
|
+
Author: Ivan Sladkov
|
|
9
|
+
Author-email: ivan.sladkov@yandex.ru
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Classifier: Framework :: AsyncIO
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Dist: hishel[httpx] (>=1.3.1,<2.0.0)
|
|
24
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
25
|
+
Requires-Dist: pydantic (>=2.13.5,<3.0.0)
|
|
26
|
+
Requires-Dist: pyrate-limiter (>=4.5.0,<5.0.0)
|
|
27
|
+
Requires-Dist: tenacity (>=9.1.4,<10.0.0)
|
|
28
|
+
Project-URL: Changelog, https://github.com/sliv2001/avia-api/blob/master/CHANGELOG.md
|
|
29
|
+
Project-URL: Homepage, https://github.com/sliv2001/avia-api
|
|
30
|
+
Project-URL: Issues, https://github.com/sliv2001/avia-api/issues
|
|
31
|
+
Project-URL: Repository, https://github.com/sliv2001/avia-api
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# avia-api
|
|
35
|
+
|
|
36
|
+
[](https://github.com/sliv2001/avia-api/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/sliv2001/avia-api/blob/master/pyproject.toml)
|
|
38
|
+
[](https://github.com/sliv2001/avia-api)
|
|
39
|
+
[](https://github.com/sliv2001/avia-api/blob/master/LICENSE)
|
|
40
|
+
|
|
41
|
+
Async Python client for the [Aviasales / Travelpayouts Data API](https://support.travelpayouts.com/hc/ru/sections/201008338-Aviasales-flight-data-API) - historical/cached ticket prices, price calendars, and reference data (countries, cities, airports, airlines, routes).
|
|
42
|
+
|
|
43
|
+
Not covered: real-time search (`Aviasales Flights Search API`) and the GraphQL API - these are separate products with a different interaction model.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
The package is available through any standard package manager:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install avia-api
|
|
51
|
+
# or
|
|
52
|
+
uv add avia-api
|
|
53
|
+
# or
|
|
54
|
+
poetry add avia-api
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires Python 3.11+.
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import asyncio
|
|
63
|
+
from avia_api import AviaApiClient
|
|
64
|
+
|
|
65
|
+
async def main() -> None:
|
|
66
|
+
async with AviaApiClient(token="YOUR_TOKEN") as client:
|
|
67
|
+
prices = await client.prices.cheap(origin="MOW", destination="LED")
|
|
68
|
+
for destination, by_index in prices.items():
|
|
69
|
+
for entry in by_index.values():
|
|
70
|
+
print(destination, entry.price, entry.airline, entry.departure_at)
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
You can also skip passing the token explicitly and put it in the `TRAVELPAYOUTS_TOKEN` environment variable - the client will pick it up automatically. Get a token in your personal dashboard: https://www.travelpayouts.com/programs/100/tools/api
|
|
76
|
+
|
|
77
|
+
## Resources and endpoints
|
|
78
|
+
|
|
79
|
+
All methods return models validated by [pydantic](https://docs.pydantic.dev/)
|
|
80
|
+
|
|
81
|
+
### `client.prices` - prices
|
|
82
|
+
|
|
83
|
+
| Method | Endpoint | Description |
|
|
84
|
+
| ------------------------------------------------- | -------------------------------------- | ------------------------------------------------ |
|
|
85
|
+
| `cheap(origin, destination, ...)` | `GET /v1/prices/cheap` | Cheapest tickets for a route |
|
|
86
|
+
| `direct(origin, destination, ...)` | `GET /v1/prices/direct` | Same, but direct flights only |
|
|
87
|
+
| `calendar(origin, destination, depart_date, ...)` | `GET /v1/prices/calendar` | Price calendar for every day of the month |
|
|
88
|
+
| `monthly(origin, destination, ...)` | `GET /v1/prices/monthly` | Lowest price by month |
|
|
89
|
+
| `latest(...)` | `GET /v2/prices/latest` | Latest found prices across the whole search base |
|
|
90
|
+
| `month_matrix(origin, destination, ...)` | `GET /v2/prices/month-matrix` | Price calendar for a month (v2) |
|
|
91
|
+
| `week_matrix(origin, destination, ...)` | `GET /v2/prices/week-matrix` | Price calendar for a week |
|
|
92
|
+
| `nearest_places_matrix(origin, destination, ...)` | `GET /v2/prices/nearest-places-matrix` | Prices for nearby airports/cities |
|
|
93
|
+
|
|
94
|
+
### `client.directions` - popular routes
|
|
95
|
+
|
|
96
|
+
| Method | Endpoint | Description |
|
|
97
|
+
| ---------------------------- | ---------------------------- | -------------------------------- |
|
|
98
|
+
| `airline(airline_code, ...)` | `GET /v1/airline-directions` | Popular routes for an airline |
|
|
99
|
+
| `city(origin, ...)` | `GET /v1/city-directions` | Popular destinations from a city |
|
|
100
|
+
|
|
101
|
+
### `client.reference` - reference data
|
|
102
|
+
|
|
103
|
+
Public, rarely changing JSON files:
|
|
104
|
+
|
|
105
|
+
| Method | Endpoint |
|
|
106
|
+
| ---------------------------------- | ---------------------------------------------- |
|
|
107
|
+
| `countries(language="en")` | `GET /data/{language}/countries.json` |
|
|
108
|
+
| `cities(language="en")` | `GET /data/{language}/cities.json` |
|
|
109
|
+
| `airports(language="en")` | `GET /data/{language}/airports.json` |
|
|
110
|
+
| `airlines(language="en")` | `GET /data/{language}/airlines.json` |
|
|
111
|
+
| `airline_alliances(language="en")` | `GET /data/{language}/airlines_alliances.json` |
|
|
112
|
+
| `planes()` | `GET /data/planes.json` |
|
|
113
|
+
| `routes()` | `GET /data/routes.json` |
|
|
114
|
+
|
|
115
|
+
## Client configuration
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from avia_api import AviaApiClient
|
|
119
|
+
from pyrate_limiter import Rate, Duration
|
|
120
|
+
|
|
121
|
+
client = AviaApiClient(
|
|
122
|
+
token="...",
|
|
123
|
+
rate=Rate(5, Duration.SECOND), # outgoing request rate limit (pyrate-limiter)
|
|
124
|
+
max_retries=3, # retries on 429/5xx and connection drops
|
|
125
|
+
cache_ttl=1800, # seconds; None disables the response cache
|
|
126
|
+
cache_path="avia_api.db", # sqlite cache file (hishel), relative path
|
|
127
|
+
# goes under .cache/hishel/
|
|
128
|
+
timeout=10.0,
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- **Rate limiting** - [pyrate-limiter](https://github.com/vutran1710/PyrateLimiter), a single bucket per client. Limits the rate of outgoing requests before they're sent, to avoid getting a `429` from the API.
|
|
133
|
+
- **Retries** - [tenacity](https://github.com/jd/tenacity) with exponential backoff and jitter; on `429` the `Retry-After` header is honored if present, but capped at 60 seconds per attempt - an unusually large value from the server (e.g. during an incident on its side) can't stall a request indefinitely.
|
|
134
|
+
- **Cache** - [hishel](https://hishel.com) on top of sqlite. Travelpayouts responses don't send `Cache-Control`, so `FilterPolicy` is used (any successful `GET` is cached, with entry lifetime governed by `cache_ttl`) instead of RFC 9111.
|
|
135
|
+
|
|
136
|
+
For tests or non-standard scenarios you can pass `transport=...` - your own `httpx.AsyncBaseTransport`, which fully disables rate limiting/retry/cache, and requests go straight through it (see `respx` or `httpx.MockTransport`).
|
|
137
|
+
|
|
138
|
+
## Error handling
|
|
139
|
+
|
|
140
|
+
All exceptions derive from `avia_api.AviaApiError`:
|
|
141
|
+
|
|
142
|
+
| Exception | When it occurs |
|
|
143
|
+
| ---------------------------- | -------------------------------------------------------------- |
|
|
144
|
+
| `AviaApiConnectionError` | Network unavailable / timeout - after retries are exhausted |
|
|
145
|
+
| `AviaApiAuthenticationError` | HTTP 401/403 - token missing or invalid |
|
|
146
|
+
| `AviaApiRateLimitError` | HTTP 429 - after retries are exhausted; has `.retry_after` |
|
|
147
|
+
| `AviaApiServerError` | HTTP 5xx - after retries are exhausted |
|
|
148
|
+
| `AviaApiHTTPStatusError` | Other HTTP errors |
|
|
149
|
+
| `AviaApiResponseError` | HTTP 200, but `{"success": false}` in the body; has `.payload` |
|
|
150
|
+
| `AviaApiValidationError` | Response doesn't match the expected schema (API changed) |
|
|
151
|
+
|
|
152
|
+
## Logging
|
|
153
|
+
|
|
154
|
+
The library uses standard `logging`. To see logs, enable the desired level for the `avia_api` logger:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
import logging
|
|
158
|
+
|
|
159
|
+
logging.basicConfig(level=logging.INFO)
|
|
160
|
+
logging.getLogger("avia_api").setLevel(logging.DEBUG)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
What is logged and at what level:
|
|
164
|
+
|
|
165
|
+
| Logger | Level | Event |
|
|
166
|
+
| --------------------- | --------- | -------------------------------------------------------------------------- |
|
|
167
|
+
| `avia_api._transport` | `DEBUG` | Delay in the rate limiter; successful request and its status |
|
|
168
|
+
| `avia_api._transport` | `WARNING` | Retry after a transient error/status; retry budget exhausted |
|
|
169
|
+
| `avia_api._client` | `DEBUG` | Outgoing request (path + query parameters) and response status |
|
|
170
|
+
| `avia_api._client` | `WARNING` | HTTP 401/403/429/4xx; `{"success": false}` in the response body |
|
|
171
|
+
| `avia_api._client` | `ERROR` | HTTP 5xx; network error after retries; response failed the pydantic schema |
|
|
172
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
avia_api/__init__.py,sha256=lsCKDjZg1jJHh_Z96BgGFDiX-woUyWZWcjFwHMjbXwM,935
|
|
2
|
+
avia_api/_client.py,sha256=ICWTeYWzm9z7Qpan2PJMJST_lkZIMCpHoicTAIEq8hs,5467
|
|
3
|
+
avia_api/_params.py,sha256=2zSYyM_Yis_eKe7z3pzuytDMjpdmJXBIlgH2Oys5LEU,523
|
|
4
|
+
avia_api/_transport.py,sha256=_gGjTUS3MTFI4xLbTZHaUsGitOH_kDwloV8gILt1VCM,6871
|
|
5
|
+
avia_api/_utils.py,sha256=Fdp6DlB_f2iCM1iEpvkGKm6PEPi7iBP2USSFitx5dTE,744
|
|
6
|
+
avia_api/exceptions.py,sha256=aWGfdGyMEo0nIRiM6yc_ymdRrrEMRZzAu-tF0Lu8_oY,1940
|
|
7
|
+
avia_api/models/__init__.py,sha256=mz8HCY5jj9hUVd4RjcCxSgpqKM5cWd28yqK76Pl4uAA,750
|
|
8
|
+
avia_api/models/common.py,sha256=eBZeqgBlJySdTFgK88IkCCqjcekJNIahYh_2b7Al9M4,1119
|
|
9
|
+
avia_api/models/directions.py,sha256=AXYzId8qB-2QGm1kEeCn__ibk2RpAzA_us4UwnMzj10,414
|
|
10
|
+
avia_api/models/prices.py,sha256=oECbmUbT2J5QOyZcBTykGBRYdS_eC1jKnf7A0M_Vu1k,2339
|
|
11
|
+
avia_api/models/reference.py,sha256=2C_VL-GIEFutEQ3qqLZjyKygWVTcVN46xhu040483-Q,1606
|
|
12
|
+
avia_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
avia_api/resources/__init__.py,sha256=Csi6VoJMmVNmPfnK-aFVQcfLGi6eFgxkV8LlwYzNGKo,192
|
|
14
|
+
avia_api/resources/_base.py,sha256=wo3g8FyISuSCvdCuzgZvF3zspo_sZOY4emMfbSB1g9Q,453
|
|
15
|
+
avia_api/resources/directions.py,sha256=p_vufhkB7zmOQ-SAC92hL2vTGx8GBhobFnM1nXwq0SQ,1382
|
|
16
|
+
avia_api/resources/prices.py,sha256=JnTewXaDOGDUkcGOkQrWFU6QtO5NCYknkeM7SY9H-W8,8416
|
|
17
|
+
avia_api/resources/reference.py,sha256=uNVIO1uWckNpCCepgWgthC2ibX4iWcF3lDHkUslh1Qk,2172
|
|
18
|
+
avia_api-0.1.0.dist-info/METADATA,sha256=PgV2VY1R3QqlOatL0qQkEpn3DmkFWDxWeeFMSyCHbP8,9797
|
|
19
|
+
avia_api-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
20
|
+
avia_api-0.1.0.dist-info/licenses/LICENSE,sha256=tpswo9THJKwmCeqW6gtxT8nVMqSYvasT4D3ddYjgeOs,1069
|
|
21
|
+
avia_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Sladkov
|
|
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.
|