avia-api 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- avia_api-0.1.0/PKG-INFO +172 -0
- avia_api-0.1.0/README.md +138 -0
- avia_api-0.1.0/pyproject.toml +101 -0
- avia_api-0.1.0/src/avia_api/__init__.py +34 -0
- avia_api-0.1.0/src/avia_api/_client.py +137 -0
- avia_api-0.1.0/src/avia_api/_params.py +14 -0
- avia_api-0.1.0/src/avia_api/_transport.py +167 -0
- avia_api-0.1.0/src/avia_api/_utils.py +24 -0
- avia_api-0.1.0/src/avia_api/exceptions.py +62 -0
- avia_api-0.1.0/src/avia_api/models/__init__.py +41 -0
- avia_api-0.1.0/src/avia_api/models/common.py +38 -0
- avia_api-0.1.0/src/avia_api/models/directions.py +19 -0
- avia_api-0.1.0/src/avia_api/models/prices.py +90 -0
- avia_api-0.1.0/src/avia_api/models/reference.py +68 -0
- avia_api-0.1.0/src/avia_api/py.typed +0 -0
- avia_api-0.1.0/src/avia_api/resources/__init__.py +5 -0
- avia_api-0.1.0/src/avia_api/resources/_base.py +18 -0
- avia_api-0.1.0/src/avia_api/resources/directions.py +43 -0
- avia_api-0.1.0/src/avia_api/resources/prices.py +249 -0
- avia_api-0.1.0/src/avia_api/resources/reference.py +50 -0
avia_api-0.1.0/LICENSE
ADDED
|
@@ -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.
|
avia_api-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
|
avia_api-0.1.0/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# avia-api
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sliv2001/avia-api/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/sliv2001/avia-api/blob/master/pyproject.toml)
|
|
5
|
+
[](https://github.com/sliv2001/avia-api)
|
|
6
|
+
[](https://github.com/sliv2001/avia-api/blob/master/LICENSE)
|
|
7
|
+
|
|
8
|
+
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).
|
|
9
|
+
|
|
10
|
+
Not covered: real-time search (`Aviasales Flights Search API`) and the GraphQL API - these are separate products with a different interaction model.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
The package is available through any standard package manager:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install avia-api
|
|
18
|
+
# or
|
|
19
|
+
uv add avia-api
|
|
20
|
+
# or
|
|
21
|
+
poetry add avia-api
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Python 3.11+.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
from avia_api import AviaApiClient
|
|
31
|
+
|
|
32
|
+
async def main() -> None:
|
|
33
|
+
async with AviaApiClient(token="YOUR_TOKEN") as client:
|
|
34
|
+
prices = await client.prices.cheap(origin="MOW", destination="LED")
|
|
35
|
+
for destination, by_index in prices.items():
|
|
36
|
+
for entry in by_index.values():
|
|
37
|
+
print(destination, entry.price, entry.airline, entry.departure_at)
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
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
|
|
43
|
+
|
|
44
|
+
## Resources and endpoints
|
|
45
|
+
|
|
46
|
+
All methods return models validated by [pydantic](https://docs.pydantic.dev/)
|
|
47
|
+
|
|
48
|
+
### `client.prices` - prices
|
|
49
|
+
|
|
50
|
+
| Method | Endpoint | Description |
|
|
51
|
+
| ------------------------------------------------- | -------------------------------------- | ------------------------------------------------ |
|
|
52
|
+
| `cheap(origin, destination, ...)` | `GET /v1/prices/cheap` | Cheapest tickets for a route |
|
|
53
|
+
| `direct(origin, destination, ...)` | `GET /v1/prices/direct` | Same, but direct flights only |
|
|
54
|
+
| `calendar(origin, destination, depart_date, ...)` | `GET /v1/prices/calendar` | Price calendar for every day of the month |
|
|
55
|
+
| `monthly(origin, destination, ...)` | `GET /v1/prices/monthly` | Lowest price by month |
|
|
56
|
+
| `latest(...)` | `GET /v2/prices/latest` | Latest found prices across the whole search base |
|
|
57
|
+
| `month_matrix(origin, destination, ...)` | `GET /v2/prices/month-matrix` | Price calendar for a month (v2) |
|
|
58
|
+
| `week_matrix(origin, destination, ...)` | `GET /v2/prices/week-matrix` | Price calendar for a week |
|
|
59
|
+
| `nearest_places_matrix(origin, destination, ...)` | `GET /v2/prices/nearest-places-matrix` | Prices for nearby airports/cities |
|
|
60
|
+
|
|
61
|
+
### `client.directions` - popular routes
|
|
62
|
+
|
|
63
|
+
| Method | Endpoint | Description |
|
|
64
|
+
| ---------------------------- | ---------------------------- | -------------------------------- |
|
|
65
|
+
| `airline(airline_code, ...)` | `GET /v1/airline-directions` | Popular routes for an airline |
|
|
66
|
+
| `city(origin, ...)` | `GET /v1/city-directions` | Popular destinations from a city |
|
|
67
|
+
|
|
68
|
+
### `client.reference` - reference data
|
|
69
|
+
|
|
70
|
+
Public, rarely changing JSON files:
|
|
71
|
+
|
|
72
|
+
| Method | Endpoint |
|
|
73
|
+
| ---------------------------------- | ---------------------------------------------- |
|
|
74
|
+
| `countries(language="en")` | `GET /data/{language}/countries.json` |
|
|
75
|
+
| `cities(language="en")` | `GET /data/{language}/cities.json` |
|
|
76
|
+
| `airports(language="en")` | `GET /data/{language}/airports.json` |
|
|
77
|
+
| `airlines(language="en")` | `GET /data/{language}/airlines.json` |
|
|
78
|
+
| `airline_alliances(language="en")` | `GET /data/{language}/airlines_alliances.json` |
|
|
79
|
+
| `planes()` | `GET /data/planes.json` |
|
|
80
|
+
| `routes()` | `GET /data/routes.json` |
|
|
81
|
+
|
|
82
|
+
## Client configuration
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from avia_api import AviaApiClient
|
|
86
|
+
from pyrate_limiter import Rate, Duration
|
|
87
|
+
|
|
88
|
+
client = AviaApiClient(
|
|
89
|
+
token="...",
|
|
90
|
+
rate=Rate(5, Duration.SECOND), # outgoing request rate limit (pyrate-limiter)
|
|
91
|
+
max_retries=3, # retries on 429/5xx and connection drops
|
|
92
|
+
cache_ttl=1800, # seconds; None disables the response cache
|
|
93
|
+
cache_path="avia_api.db", # sqlite cache file (hishel), relative path
|
|
94
|
+
# goes under .cache/hishel/
|
|
95
|
+
timeout=10.0,
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- **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.
|
|
100
|
+
- **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.
|
|
101
|
+
- **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.
|
|
102
|
+
|
|
103
|
+
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`).
|
|
104
|
+
|
|
105
|
+
## Error handling
|
|
106
|
+
|
|
107
|
+
All exceptions derive from `avia_api.AviaApiError`:
|
|
108
|
+
|
|
109
|
+
| Exception | When it occurs |
|
|
110
|
+
| ---------------------------- | -------------------------------------------------------------- |
|
|
111
|
+
| `AviaApiConnectionError` | Network unavailable / timeout - after retries are exhausted |
|
|
112
|
+
| `AviaApiAuthenticationError` | HTTP 401/403 - token missing or invalid |
|
|
113
|
+
| `AviaApiRateLimitError` | HTTP 429 - after retries are exhausted; has `.retry_after` |
|
|
114
|
+
| `AviaApiServerError` | HTTP 5xx - after retries are exhausted |
|
|
115
|
+
| `AviaApiHTTPStatusError` | Other HTTP errors |
|
|
116
|
+
| `AviaApiResponseError` | HTTP 200, but `{"success": false}` in the body; has `.payload` |
|
|
117
|
+
| `AviaApiValidationError` | Response doesn't match the expected schema (API changed) |
|
|
118
|
+
|
|
119
|
+
## Logging
|
|
120
|
+
|
|
121
|
+
The library uses standard `logging`. To see logs, enable the desired level for the `avia_api` logger:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import logging
|
|
125
|
+
|
|
126
|
+
logging.basicConfig(level=logging.INFO)
|
|
127
|
+
logging.getLogger("avia_api").setLevel(logging.DEBUG)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
What is logged and at what level:
|
|
131
|
+
|
|
132
|
+
| Logger | Level | Event |
|
|
133
|
+
| --------------------- | --------- | -------------------------------------------------------------------------- |
|
|
134
|
+
| `avia_api._transport` | `DEBUG` | Delay in the rate limiter; successful request and its status |
|
|
135
|
+
| `avia_api._transport` | `WARNING` | Retry after a transient error/status; retry budget exhausted |
|
|
136
|
+
| `avia_api._client` | `DEBUG` | Outgoing request (path + query parameters) and response status |
|
|
137
|
+
| `avia_api._client` | `WARNING` | HTTP 401/403/429/4xx; `{"success": false}` in the response body |
|
|
138
|
+
| `avia_api._client` | `ERROR` | HTTP 5xx; network error after retries; response failed the pydantic schema |
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "avia-api"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Async Python client for the Travelpayouts / Aviasales Data API"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Ivan Sladkov",email = "ivan.sladkov@yandex.ru"}
|
|
7
|
+
]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
keywords = [
|
|
12
|
+
"travelpayouts",
|
|
13
|
+
"aviasales",
|
|
14
|
+
"flights",
|
|
15
|
+
"airline-tickets",
|
|
16
|
+
"prices",
|
|
17
|
+
"api-client",
|
|
18
|
+
"async",
|
|
19
|
+
"httpx",
|
|
20
|
+
"pydantic",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Programming Language :: Python :: 3.14",
|
|
31
|
+
"Typing :: Typed",
|
|
32
|
+
"Framework :: AsyncIO",
|
|
33
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
34
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
+
]
|
|
36
|
+
dependencies = [
|
|
37
|
+
"httpx (>=0.28.1,<0.29.0)",
|
|
38
|
+
"pyrate-limiter (>=4.5.0,<5.0.0)",
|
|
39
|
+
"tenacity (>=9.1.4,<10.0.0)",
|
|
40
|
+
"pydantic (>=2.13.5,<3.0.0)",
|
|
41
|
+
"hishel[httpx] (>=1.3.1,<2.0.0)"
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/sliv2001/avia-api"
|
|
46
|
+
Repository = "https://github.com/sliv2001/avia-api"
|
|
47
|
+
Issues = "https://github.com/sliv2001/avia-api/issues"
|
|
48
|
+
Changelog = "https://github.com/sliv2001/avia-api/blob/master/CHANGELOG.md"
|
|
49
|
+
|
|
50
|
+
[tool.poetry]
|
|
51
|
+
packages = [{include = "avia_api", from = "src"}]
|
|
52
|
+
|
|
53
|
+
[build-system]
|
|
54
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
55
|
+
build-backend = "poetry.core.masonry.api"
|
|
56
|
+
|
|
57
|
+
[dependency-groups]
|
|
58
|
+
dev = [
|
|
59
|
+
"pytest (>=9.1.1,<10.0.0)",
|
|
60
|
+
"pytest-asyncio (>=1.4.0,<2.0.0)",
|
|
61
|
+
"respx (>=0.23.1,<0.24.0)",
|
|
62
|
+
"pytest-cov (>=7.1.0,<8.0.0)",
|
|
63
|
+
"ruff (>=0.14.0,<0.15.0)",
|
|
64
|
+
"mypy (>=1.19.0,<2.0.0)"
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
asyncio_mode = "auto"
|
|
69
|
+
testpaths = ["tests"]
|
|
70
|
+
|
|
71
|
+
[tool.coverage.run]
|
|
72
|
+
source = ["src/avia_api"]
|
|
73
|
+
|
|
74
|
+
[tool.coverage.report]
|
|
75
|
+
fail_under = 99
|
|
76
|
+
exclude_also = [
|
|
77
|
+
"if t\\.TYPE_CHECKING:",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
line-length = 120
|
|
82
|
+
target-version = "py311"
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint]
|
|
85
|
+
select = [
|
|
86
|
+
"E", # pycodestyle
|
|
87
|
+
"F", # pyflakes
|
|
88
|
+
"I", # isort
|
|
89
|
+
"UP", # pyupgrade
|
|
90
|
+
"B", # flake8-bugbear
|
|
91
|
+
"SIM", # flake8-simplify
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[tool.mypy]
|
|
95
|
+
python_version = "3.11"
|
|
96
|
+
strict = true
|
|
97
|
+
files = ["src/avia_api"]
|
|
98
|
+
|
|
99
|
+
[[tool.mypy.overrides]]
|
|
100
|
+
module = "hishel.*"
|
|
101
|
+
ignore_missing_imports = true
|
|
@@ -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
|
+
]
|
|
@@ -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)
|
|
@@ -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}
|