aqi-in-api 0.0.2__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.
- aqi_in_api/__init__.py +9 -0
- aqi_in_api/_client.py +238 -0
- aqi_in_api/_constants.py +19 -0
- aqi_in_api/_exceptions.py +12 -0
- aqi_in_api/_token.py +17 -0
- aqi_in_api/_utils.py +34 -0
- aqi_in_api/models.py +229 -0
- aqi_in_api-0.0.2.dist-info/METADATA +119 -0
- aqi_in_api-0.0.2.dist-info/RECORD +10 -0
- aqi_in_api-0.0.2.dist-info/WHEEL +4 -0
aqi_in_api/__init__.py
ADDED
aqi_in_api/_client.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import typing
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from aqi_in_api._constants import DEFAULT_BASE_URL, DEFAULT_USER_AGENT, ENDPOINTS
|
|
11
|
+
from aqi_in_api._exceptions import AQIException
|
|
12
|
+
from aqi_in_api._token import generate_token
|
|
13
|
+
from aqi_in_api._utils import build_url, get_slug_depth
|
|
14
|
+
from aqi_in_api.models import (
|
|
15
|
+
HistoryData,
|
|
16
|
+
HistoryDataWithWHO,
|
|
17
|
+
IPDetails,
|
|
18
|
+
LocationDetails,
|
|
19
|
+
LocationType,
|
|
20
|
+
RankingEntry,
|
|
21
|
+
RankType,
|
|
22
|
+
SearchResults,
|
|
23
|
+
SearchType,
|
|
24
|
+
SensorName,
|
|
25
|
+
SlugType,
|
|
26
|
+
Station,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ClientConfig:
|
|
34
|
+
token: str | None = None
|
|
35
|
+
base_url: str = DEFAULT_BASE_URL
|
|
36
|
+
user_agent: str = DEFAULT_USER_AGENT
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _from_dict(model_cls: type[T], data: dict[str, Any]) -> T:
|
|
40
|
+
"""Convert a nested dict into a frozen dataclass instance, skipping unknown fields."""
|
|
41
|
+
if not dataclasses.is_dataclass(model_cls) or not isinstance(data, dict):
|
|
42
|
+
return data # type: ignore[return-value]
|
|
43
|
+
kwargs: dict[str, Any] = {}
|
|
44
|
+
for field in dataclasses.fields(model_cls):
|
|
45
|
+
if field.name not in data:
|
|
46
|
+
continue
|
|
47
|
+
kwargs[field.name] = _convert_value(field.type, data[field.name])
|
|
48
|
+
return model_cls(**kwargs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _convert_value(field_type: object, value: Any) -> Any:
|
|
52
|
+
if value is None:
|
|
53
|
+
return None
|
|
54
|
+
origin = typing.get_origin(field_type)
|
|
55
|
+
args = typing.get_args(field_type)
|
|
56
|
+
if origin is typing.Union:
|
|
57
|
+
non_none = [a for a in args if a is not type(None)]
|
|
58
|
+
if non_none:
|
|
59
|
+
return _convert_value(non_none[0], value)
|
|
60
|
+
return None
|
|
61
|
+
if origin is list and args and isinstance(value, list):
|
|
62
|
+
return [_convert_value(args[0], item) for item in value]
|
|
63
|
+
if origin is tuple and args and isinstance(value, (list, tuple)):
|
|
64
|
+
return tuple(_convert_value(args[i], v) for i, v in enumerate(value[: len(args)]))
|
|
65
|
+
try:
|
|
66
|
+
if isinstance(value, dict) and dataclasses.is_dataclass(field_type):
|
|
67
|
+
return _from_dict(field_type, value) # type: ignore[arg-type]
|
|
68
|
+
except TypeError:
|
|
69
|
+
pass
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _to_model(model_cls: type[T], data: Any) -> Any:
|
|
74
|
+
if isinstance(data, list):
|
|
75
|
+
return [_from_dict(model_cls, item) for item in data]
|
|
76
|
+
return _from_dict(model_cls, data)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AQIClient:
|
|
80
|
+
def __init__(self, config: ClientConfig | None = None) -> None:
|
|
81
|
+
config = config or ClientConfig()
|
|
82
|
+
self._base_url = config.base_url
|
|
83
|
+
self._custom_token = config.token
|
|
84
|
+
self._user_agent = config.user_agent
|
|
85
|
+
self._cached_token: str | None = None
|
|
86
|
+
self._http = httpx.AsyncClient()
|
|
87
|
+
|
|
88
|
+
async def _get_token(self) -> str:
|
|
89
|
+
if self._custom_token is not None:
|
|
90
|
+
return self._custom_token
|
|
91
|
+
if self._cached_token is None:
|
|
92
|
+
self._cached_token = generate_token()
|
|
93
|
+
return self._cached_token
|
|
94
|
+
|
|
95
|
+
async def _request(
|
|
96
|
+
self,
|
|
97
|
+
endpoint: str,
|
|
98
|
+
params: dict[str, str | int | float | None] | None = None,
|
|
99
|
+
) -> Any:
|
|
100
|
+
url = build_url(self._base_url, endpoint, params)
|
|
101
|
+
token = await self._get_token()
|
|
102
|
+
|
|
103
|
+
response = await self._http.get(
|
|
104
|
+
url,
|
|
105
|
+
headers={
|
|
106
|
+
"User-Agent": self._user_agent,
|
|
107
|
+
"authorization": f"bearer {token}",
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if not response.is_success:
|
|
112
|
+
raise AQIException(
|
|
113
|
+
f"Request failed: {response.status_code} {response.reason_phrase}",
|
|
114
|
+
response.status_code,
|
|
115
|
+
response.text,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
body: dict[str, Any] = response.json()
|
|
119
|
+
|
|
120
|
+
if body.get("status") in ("failed",):
|
|
121
|
+
raise AQIException(
|
|
122
|
+
body.get("message") or body.get("error") or "Unknown error",
|
|
123
|
+
body.get("status_code", 400),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return body["data"]
|
|
127
|
+
|
|
128
|
+
async def get_ip_details(self) -> IPDetails:
|
|
129
|
+
data = await self._request(ENDPOINTS["IP_DETAILS"])
|
|
130
|
+
return _from_dict(IPDetails, data)
|
|
131
|
+
|
|
132
|
+
async def get_nearest_location(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
lat: float,
|
|
136
|
+
long: float,
|
|
137
|
+
type: LocationType | None = None,
|
|
138
|
+
) -> list[Station]:
|
|
139
|
+
data = await self._request(
|
|
140
|
+
ENDPOINTS["NEAREST_LOCATION"],
|
|
141
|
+
{"lat": lat, "long": long, "type": "2" if type == "city" else "1"},
|
|
142
|
+
)
|
|
143
|
+
return _to_model(Station, data) # type: ignore[no-any-return]
|
|
144
|
+
|
|
145
|
+
async def get_location_by_slug(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
slug: str,
|
|
149
|
+
type: SlugType | None = None,
|
|
150
|
+
) -> list[LocationDetails]:
|
|
151
|
+
data = await self._request(
|
|
152
|
+
ENDPOINTS["LOCATION_BY_SLUG"],
|
|
153
|
+
{
|
|
154
|
+
"slug": slug,
|
|
155
|
+
"type": type if type is not None else get_slug_depth(slug),
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
return _to_model(LocationDetails, data) # type: ignore[no-any-return]
|
|
159
|
+
|
|
160
|
+
async def search(self, *, search_string: str) -> SearchResults:
|
|
161
|
+
data = await self._request(ENDPOINTS["SEARCH"], {"searchString": search_string})
|
|
162
|
+
return _from_dict(SearchResults, data)
|
|
163
|
+
|
|
164
|
+
async def get_last_12_hour_history(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
slug: str,
|
|
168
|
+
sensorname: SensorName,
|
|
169
|
+
slug_type: SearchType,
|
|
170
|
+
) -> HistoryData:
|
|
171
|
+
data = await self._request(
|
|
172
|
+
ENDPOINTS["HISTORY_12H"],
|
|
173
|
+
{"slug": slug, "sensorname": sensorname, "slugType": slug_type},
|
|
174
|
+
)
|
|
175
|
+
return _from_dict(HistoryData, data)
|
|
176
|
+
|
|
177
|
+
async def get_last_24_hour_history(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
slug: str,
|
|
181
|
+
sensorname: SensorName,
|
|
182
|
+
slug_type: SearchType,
|
|
183
|
+
) -> HistoryDataWithWHO:
|
|
184
|
+
data = await self._request(
|
|
185
|
+
ENDPOINTS["HISTORY_24H"],
|
|
186
|
+
{"slug": slug, "sensorname": sensorname, "slugType": slug_type},
|
|
187
|
+
)
|
|
188
|
+
return _from_dict(HistoryDataWithWHO, data)
|
|
189
|
+
|
|
190
|
+
async def get_last_7_days_history(
|
|
191
|
+
self,
|
|
192
|
+
*,
|
|
193
|
+
slug: str,
|
|
194
|
+
sensorname: SensorName,
|
|
195
|
+
slug_type: SearchType,
|
|
196
|
+
) -> HistoryDataWithWHO:
|
|
197
|
+
data = await self._request(
|
|
198
|
+
ENDPOINTS["HISTORY_7D"],
|
|
199
|
+
{"slug": slug, "sensorname": sensorname, "slugType": slug_type},
|
|
200
|
+
)
|
|
201
|
+
return _from_dict(HistoryDataWithWHO, data)
|
|
202
|
+
|
|
203
|
+
async def get_last_30_days_history(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
slug: str,
|
|
207
|
+
sensorname: SensorName,
|
|
208
|
+
slug_type: SearchType,
|
|
209
|
+
) -> HistoryDataWithWHO:
|
|
210
|
+
data = await self._request(
|
|
211
|
+
ENDPOINTS["HISTORY_30D"],
|
|
212
|
+
{"slug": slug, "sensorname": sensorname, "slugType": slug_type},
|
|
213
|
+
)
|
|
214
|
+
return _from_dict(HistoryDataWithWHO, data)
|
|
215
|
+
|
|
216
|
+
async def get_rankings(
|
|
217
|
+
self,
|
|
218
|
+
*,
|
|
219
|
+
sensorname: SensorName,
|
|
220
|
+
type: RankType,
|
|
221
|
+
limit: int = 10,
|
|
222
|
+
) -> list[RankingEntry]:
|
|
223
|
+
data = await self._request(
|
|
224
|
+
ENDPOINTS["RANKINGS"],
|
|
225
|
+
{
|
|
226
|
+
"sensorname": sensorname,
|
|
227
|
+
"type": "1" if type == "country" else "2",
|
|
228
|
+
"limit": limit,
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
return _to_model(RankingEntry, data) # type: ignore[no-any-return]
|
|
232
|
+
|
|
233
|
+
async def close(self) -> None:
|
|
234
|
+
await self._http.aclose()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def create_aqi_client(config: ClientConfig | None = None) -> AQIClient:
|
|
238
|
+
return AQIClient(config)
|
aqi_in_api/_constants.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
DEFAULT_BASE_URL = "https://apiserver.aqi.in"
|
|
2
|
+
|
|
3
|
+
DEFAULT_USER_AGENT = (
|
|
4
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
5
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
6
|
+
"Chrome/143.0.0.0 Safari/537.36"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
ENDPOINTS = {
|
|
10
|
+
"NEAREST_LOCATION": "/aqi/v2/getNearestLocation",
|
|
11
|
+
"IP_DETAILS": "/service/get/ip/details",
|
|
12
|
+
"LOCATION_BY_SLUG": "/aqi/v2/getLocationDetailsBySlug",
|
|
13
|
+
"SEARCH": "/aqi/searchLocationCityStateCountry",
|
|
14
|
+
"HISTORY_12H": "/aqi/getLast12HourHistory",
|
|
15
|
+
"HISTORY_24H": "/aqi/v3/getLast24HourHistory",
|
|
16
|
+
"HISTORY_7D": "/aqi/getLast7DaysHistory",
|
|
17
|
+
"HISTORY_30D": "/aqi/getLast30DaysHistory",
|
|
18
|
+
"RANKINGS": "/aqi/getAirQualityRanklistCountryAndCity",
|
|
19
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class AQIException(Exception):
|
|
2
|
+
def __init__(self, message: str, status_code: int, body: str | None = None) -> None:
|
|
3
|
+
super().__init__(message)
|
|
4
|
+
self.message = message
|
|
5
|
+
self.status_code = status_code
|
|
6
|
+
self.body = body
|
|
7
|
+
self.name = "AQIException"
|
|
8
|
+
|
|
9
|
+
def __str__(self) -> str:
|
|
10
|
+
if self.body:
|
|
11
|
+
return f"AQIException({self.status_code}): {self.message} - {self.body}"
|
|
12
|
+
return f"AQIException({self.status_code}): {self.message}"
|
aqi_in_api/_token.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import jwt
|
|
4
|
+
|
|
5
|
+
JWT_SECRET = "masai"
|
|
6
|
+
TOKEN_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_token() -> str:
|
|
10
|
+
"""Generate a JWT token matching the AQI.in API expectations."""
|
|
11
|
+
now = int(time.time())
|
|
12
|
+
payload = {
|
|
13
|
+
"userID": 1,
|
|
14
|
+
"iat": now,
|
|
15
|
+
"exp": now + TOKEN_EXPIRY_SECONDS,
|
|
16
|
+
}
|
|
17
|
+
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
aqi_in_api/_utils.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from urllib.parse import urlencode, urljoin
|
|
3
|
+
|
|
4
|
+
from aqi_in_api.models import SlugType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_url(
|
|
8
|
+
base_url: str,
|
|
9
|
+
endpoint: str,
|
|
10
|
+
params: Mapping[str, str | int | float | None] | None = None,
|
|
11
|
+
) -> str:
|
|
12
|
+
"""Build a URL from base, endpoint, and query parameters.
|
|
13
|
+
|
|
14
|
+
Always appends ?source=web to match the TS SDK behavior.
|
|
15
|
+
"""
|
|
16
|
+
url = urljoin(base_url.rstrip("/") + "/", endpoint.lstrip("/"))
|
|
17
|
+
query_params: dict[str, str] = {"source": "web"}
|
|
18
|
+
|
|
19
|
+
if params:
|
|
20
|
+
for key, value in params.items():
|
|
21
|
+
if value is not None:
|
|
22
|
+
query_params[key] = str(value)
|
|
23
|
+
|
|
24
|
+
return f"{url}?{urlencode(query_params)}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_slug_depth(slug: str) -> SlugType:
|
|
28
|
+
"""Determine the slug depth (1=country, 2=state, 3=city, 4=station)."""
|
|
29
|
+
depth = slug.count("/") + 1
|
|
30
|
+
if depth < 1:
|
|
31
|
+
return 1
|
|
32
|
+
if depth > 4:
|
|
33
|
+
return 4
|
|
34
|
+
return depth # type: ignore[return-value]
|
aqi_in_api/models.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
SensorName = Literal["pm25", "pm10", "aqi", "AQI-IN", "co", "no2", "o3", "so2"]
|
|
7
|
+
SearchType = Literal["locationId", "cityId", "stateId", "countryId"]
|
|
8
|
+
LocationType = Literal["station", "city"]
|
|
9
|
+
SlugType = Literal[1, 2, 3, 4]
|
|
10
|
+
RankType = Literal["city", "country"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class IAQI:
|
|
15
|
+
AQI_IN: int | None = None
|
|
16
|
+
aqi: int | None = None
|
|
17
|
+
pm25: int | None = None
|
|
18
|
+
pm10: int | None = None
|
|
19
|
+
co: int | None = None
|
|
20
|
+
no2: int | None = None
|
|
21
|
+
o3: int | None = None
|
|
22
|
+
so2: int | None = None
|
|
23
|
+
noise: int | None = None
|
|
24
|
+
tvoc: int | None = None
|
|
25
|
+
t: float | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class WeatherCondition:
|
|
30
|
+
text: str
|
|
31
|
+
icon: str
|
|
32
|
+
code: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class UVCondition:
|
|
37
|
+
text: str
|
|
38
|
+
color_code: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Weather:
|
|
43
|
+
uid: str
|
|
44
|
+
cloud: int
|
|
45
|
+
condition: WeatherCondition
|
|
46
|
+
feelslike_c: float
|
|
47
|
+
feelslike_f: float
|
|
48
|
+
gust_kph: float
|
|
49
|
+
gust_mph: float
|
|
50
|
+
humidity: int
|
|
51
|
+
is_day: int
|
|
52
|
+
last_updated: str
|
|
53
|
+
last_updated_epoch: int
|
|
54
|
+
precip_in: float
|
|
55
|
+
precip_mm: float
|
|
56
|
+
pressure_in: float
|
|
57
|
+
pressure_mb: float
|
|
58
|
+
temp_c: float
|
|
59
|
+
temp_f: float
|
|
60
|
+
uv: float
|
|
61
|
+
vis_km: float
|
|
62
|
+
vis_miles: float
|
|
63
|
+
wind_degree: int
|
|
64
|
+
wind_dir: str
|
|
65
|
+
wind_kph: float
|
|
66
|
+
wind_mph: float
|
|
67
|
+
uv_condition: UVCondition | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class WeatherSimple:
|
|
72
|
+
temp_c: float
|
|
73
|
+
temp_f: float
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, kw_only=True)
|
|
77
|
+
class BaseLocation:
|
|
78
|
+
location: str
|
|
79
|
+
locationId: str
|
|
80
|
+
slug: str
|
|
81
|
+
latitude: float
|
|
82
|
+
longitude: float
|
|
83
|
+
flag: str
|
|
84
|
+
searchType: SearchType
|
|
85
|
+
uid: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Station(BaseLocation):
|
|
90
|
+
station: str
|
|
91
|
+
city: str
|
|
92
|
+
state: str
|
|
93
|
+
country: str
|
|
94
|
+
location_slug: str
|
|
95
|
+
city_slug: str
|
|
96
|
+
state_slug: str
|
|
97
|
+
country_slug: str
|
|
98
|
+
time_zone: str
|
|
99
|
+
coordinates: tuple[float, float]
|
|
100
|
+
background_image: str
|
|
101
|
+
city_lat: float
|
|
102
|
+
city_lon: float
|
|
103
|
+
state_lat: float
|
|
104
|
+
state_lon: float
|
|
105
|
+
country_lat: float
|
|
106
|
+
country_lon: float
|
|
107
|
+
isOnline: bool
|
|
108
|
+
isRankedCity: bool
|
|
109
|
+
iaqi: IAQI
|
|
110
|
+
updated_at: str
|
|
111
|
+
weather: Weather | None = None
|
|
112
|
+
updatedAt: str | None = None
|
|
113
|
+
createdAt: str | None = None
|
|
114
|
+
distance: float | None = None
|
|
115
|
+
source: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class City(BaseLocation):
|
|
120
|
+
city: str
|
|
121
|
+
state: str
|
|
122
|
+
country: str
|
|
123
|
+
weather: WeatherSimple | None = None
|
|
124
|
+
iaqi: IAQI | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class State(BaseLocation):
|
|
129
|
+
state: str
|
|
130
|
+
country: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Country(BaseLocation):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class IPDetails:
|
|
140
|
+
city: str
|
|
141
|
+
country: str
|
|
142
|
+
countryCode: str
|
|
143
|
+
lat: float
|
|
144
|
+
lon: float
|
|
145
|
+
offset: int
|
|
146
|
+
query: str
|
|
147
|
+
regionName: str
|
|
148
|
+
status: str
|
|
149
|
+
timezone: str
|
|
150
|
+
zip: str
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class LocationDetails:
|
|
155
|
+
uid: str
|
|
156
|
+
location: str
|
|
157
|
+
country: str
|
|
158
|
+
time_zone: str
|
|
159
|
+
latitude: float
|
|
160
|
+
longitude: float
|
|
161
|
+
country_slug: str
|
|
162
|
+
background_image: str
|
|
163
|
+
flag: str
|
|
164
|
+
country_lat: float
|
|
165
|
+
country_lon: float
|
|
166
|
+
isOnline: bool
|
|
167
|
+
isRankedCity: bool
|
|
168
|
+
iaqi: IAQI
|
|
169
|
+
updated_at: str
|
|
170
|
+
locationId: str
|
|
171
|
+
searchType: SearchType
|
|
172
|
+
slug: str
|
|
173
|
+
station: str | None = None
|
|
174
|
+
city: str | None = None
|
|
175
|
+
state: str | None = None
|
|
176
|
+
location_slug: str | None = None
|
|
177
|
+
city_slug: str | None = None
|
|
178
|
+
state_slug: str | None = None
|
|
179
|
+
city_lat: float | None = None
|
|
180
|
+
city_lon: float | None = None
|
|
181
|
+
state_lat: float | None = None
|
|
182
|
+
state_lon: float | None = None
|
|
183
|
+
weather: Weather | None = None
|
|
184
|
+
updatedAt: str | None = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class HistoryData:
|
|
189
|
+
minValue: float
|
|
190
|
+
maxValue: float
|
|
191
|
+
avgValue: float
|
|
192
|
+
averageArray: list[float]
|
|
193
|
+
timeArray: list[str]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class WHOGuideData:
|
|
198
|
+
Data: HistoryData
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class HistoryDataWithWHO(HistoryData):
|
|
203
|
+
whoguidedata: WHOGuideData | None = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class SearchResults:
|
|
208
|
+
countries: list[Country]
|
|
209
|
+
states: list[State]
|
|
210
|
+
cities: list[City]
|
|
211
|
+
stations: list[Station]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class RankingEntry:
|
|
216
|
+
location: str
|
|
217
|
+
locationId: str
|
|
218
|
+
flag: str
|
|
219
|
+
slug: str
|
|
220
|
+
latitude: float
|
|
221
|
+
longitude: float
|
|
222
|
+
updated_at: str
|
|
223
|
+
rank: int
|
|
224
|
+
city: str | None = None
|
|
225
|
+
state: str | None = None
|
|
226
|
+
country: str | None = None
|
|
227
|
+
pm25: float | None = None
|
|
228
|
+
pm10: float | None = None
|
|
229
|
+
aqi: float | None = None
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aqi-in-api
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Python SDK for the AQI.in Air Quality API
|
|
5
|
+
Project-URL: Repository, https://github.com/GuyKh/py-aqi-in-api
|
|
6
|
+
Project-URL: Issues, https://github.com/GuyKh/py-aqi-in-api/issues
|
|
7
|
+
Author: GuyKh
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: httpx>=0.28.0
|
|
11
|
+
Requires-Dist: pyjwt>=2.10.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# py-aqi-in-api
|
|
15
|
+
|
|
16
|
+
Python SDK for the [AQI.in](https://aqi.in) Air Quality API.
|
|
17
|
+
|
|
18
|
+
Fully typed, async, using modern Python (3.11+), dataclasses, and httpx.
|
|
19
|
+
|
|
20
|
+
> **Source**: This is a Python port of the [aqi-in-api](https://github.com/neo773/aqi-in-api) TypeScript SDK by [@neo773](https://github.com/neo773).
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install py-aqi-in-api
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Python 3.11+.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
|
|
35
|
+
from aqi_in_api import AQIClient
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def main() -> None:
|
|
39
|
+
client = AQIClient()
|
|
40
|
+
|
|
41
|
+
ip_details = await client.get_ip_details()
|
|
42
|
+
print(f"Location: {ip_details.city}, {ip_details.country}")
|
|
43
|
+
|
|
44
|
+
nearest = await client.get_nearest_location(
|
|
45
|
+
lat=ip_details.lat, long=ip_details.lon,
|
|
46
|
+
)
|
|
47
|
+
station = nearest[0]
|
|
48
|
+
print(f"Nearest station: {station.station} ({station.location_slug})")
|
|
49
|
+
|
|
50
|
+
location = await client.get_location_by_slug(slug=station.location_slug)
|
|
51
|
+
print(f"Location AQI: {location[0].iaqi}")
|
|
52
|
+
|
|
53
|
+
history = await client.get_last_24_hour_history(
|
|
54
|
+
slug=station.location_slug, sensorname="pm25", slug_type="locationId",
|
|
55
|
+
)
|
|
56
|
+
print(f"24h PM2.5 avg: {history.avgValue}")
|
|
57
|
+
|
|
58
|
+
await client.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
### `create_aqi_client(config?)`
|
|
67
|
+
|
|
68
|
+
| Option | Type | Required | Description |
|
|
69
|
+
|--------|------|----------|-------------|
|
|
70
|
+
| `token` | `str \| None` | No | JWT authentication token (auto-generated if omitted) |
|
|
71
|
+
| `base_url` | `str` | No | API base URL (default: `https://apiserver.aqi.in`) |
|
|
72
|
+
| `user_agent` | `str` | No | Custom user agent |
|
|
73
|
+
|
|
74
|
+
### Methods
|
|
75
|
+
|
|
76
|
+
All methods take keyword-only arguments. No `*Params` objects needed.
|
|
77
|
+
|
|
78
|
+
| Method | Keyword Args | Returns | Description |
|
|
79
|
+
|--------|-------------|---------|-------------|
|
|
80
|
+
| `get_ip_details()` | — | `IPDetails` | Get location from your IP address |
|
|
81
|
+
| `get_nearest_location(**kwargs)` | `lat`, `long`, `type?` | `list[Station]` | Get nearest monitoring stations by coordinates |
|
|
82
|
+
| `get_location_by_slug(**kwargs)` | `slug`, `type?` | `list[LocationDetails]` | Get location details by slug |
|
|
83
|
+
| `search(**kwargs)` | `search_string` | `SearchResults` | Search locations by name |
|
|
84
|
+
| `get_last_12_hour_history(**kwargs)` | `slug`, `sensorname`, `slug_type` | `HistoryData` | Get 12-hour sensor history |
|
|
85
|
+
| `get_last_24_hour_history(**kwargs)` | `slug`, `sensorname`, `slug_type` | `HistoryDataWithWHO` | Get 24-hour history with WHO guidelines |
|
|
86
|
+
| `get_last_7_days_history(**kwargs)` | `slug`, `sensorname`, `slug_type` | `HistoryDataWithWHO` | Get 7-day sensor history |
|
|
87
|
+
| `get_last_30_days_history(**kwargs)` | `slug`, `sensorname`, `slug_type` | `HistoryDataWithWHO` | Get 30-day sensor history |
|
|
88
|
+
| `get_rankings(**kwargs)` | `sensorname`, `type`, `limit=10` | `list[RankingEntry]` | Get city or country pollution rankings |
|
|
89
|
+
| `close()` | — | `None` | Close the underlying HTTP client |
|
|
90
|
+
|
|
91
|
+
### Types
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from aqi_in_api.models import (
|
|
95
|
+
Station, City, State, Country,
|
|
96
|
+
LocationDetails, IPDetails, SearchResults, RankingEntry,
|
|
97
|
+
HistoryData, HistoryDataWithWHO,
|
|
98
|
+
IAQI, Weather, WeatherCondition, WeatherSimple, UVCondition,
|
|
99
|
+
SensorName, SearchType, SlugType, LocationType, RankType,
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
git clone https://github.com/GuyKh/py-aqi-in-api
|
|
107
|
+
cd py-aqi-in-api
|
|
108
|
+
uv sync --dev
|
|
109
|
+
uv run pytest
|
|
110
|
+
uv run ruff check .
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
116
|
+
|
|
117
|
+
## Disclaimer
|
|
118
|
+
|
|
119
|
+
This is an **unofficial** API client and is not affiliated with, endorsed by, or associated with AQI.in or its parent organization. This package is provided for educational and informational purposes under fair use.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
aqi_in_api/__init__.py,sha256=tOi5xmSkkC73UJ0O_SWO5yCPxr-jm8XIsIN7CXcP5Fg,219
|
|
2
|
+
aqi_in_api/_client.py,sha256=JAlDVsFHx8SO47Nb95tpc2ULcc0UNZHDhuyW7hsMqac,7358
|
|
3
|
+
aqi_in_api/_constants.py,sha256=HQQPFkx2YCZLvkLyh-6ETM0dvcUIwOxYxficu8jKFH4,692
|
|
4
|
+
aqi_in_api/_exceptions.py,sha256=er7r1oTU_deiN41HsEsJJVKOn_vuoM_eI0QMcYZ92Lw,489
|
|
5
|
+
aqi_in_api/_token.py,sha256=bW4W1T2zJ81VP2Sa2wK39xVpqNgXMdvgYn_w7S1T8Ao,381
|
|
6
|
+
aqi_in_api/_utils.py,sha256=YZU3LHL46hl5MuQf00jrHtmS4_g1O8AFLRufoqGiFy8,960
|
|
7
|
+
aqi_in_api/models.py,sha256=OrhVBX1QTWT_tWk2m-6q18EATaAiYNwJY_HlSBjNhDk,4593
|
|
8
|
+
aqi_in_api-0.0.2.dist-info/METADATA,sha256=Su3lS1yPGCxxhXOPTsdhjaDt0nRmoLSrXNXLkFCFQPk,3944
|
|
9
|
+
aqi_in_api-0.0.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
aqi_in_api-0.0.2.dist-info/RECORD,,
|