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 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,5 @@
1
+ from .directions import DirectionsResource
2
+ from .prices import PricesResource
3
+ from .reference import ReferenceResource
4
+
5
+ __all__ = ["PricesResource", "DirectionsResource", "ReferenceResource"]
@@ -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
+ [![CI](https://github.com/sliv2001/avia-api/actions/workflows/ci.yml/badge.svg)](https://github.com/sliv2001/avia-api/actions/workflows/ci.yml)
37
+ [![coverage](https://img.shields.io/badge/coverage-99%25%2B-brightgreen)](https://github.com/sliv2001/avia-api/blob/master/pyproject.toml)
38
+ [![python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://github.com/sliv2001/avia-api)
39
+ [![license](https://img.shields.io/badge/license-MIT-blue)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.