"""Asynchronous Python client for the WAQI API.""" from __future__ import annotations import asyncio from dataclasses import dataclass from importlib import metadata from typing import TYPE_CHECKING, Any, cast from aiohttp import ClientSession from aiohttp.hdrs import METH_GET from yarl import URL from .exceptions import ( WAQIAuthenticationError, WAQIConnectionError, WAQIError, WAQIUnknownCityError, WAQIUnknownStationError, ) from .models import WAQIAirQuality, WAQISearchResult if TYPE_CHECKING: from typing_extensions import Self VERSION = metadata.version(__package__) @dataclass class WAQIClient: """Main class for handling connections with WAQI.""" session: ClientSession | None = None request_timeout: int = 10 api_host: str = "api.waqi.info" _token: str | None = None _close_session: bool = False def authenticate(self, token: str) -> None: """Authenticate the user with a token.""" self._token = token async def _request( self, uri: str, *, data: dict[str, Any] | None = None, ) -> dict[str, Any]: """Handle a request to WAQI. A generic method for sending/handling HTTP requests done against WAQI. Args: ---- uri: the path to call. data: the query parameters to add. Returns: ------- A Python dictionary (JSON decoded) with the response from the API. Raises: ------ WAQIConnectionError: An error occurred while communicating with the WAQI API. WAQIError: Received an unexpected response from the WAQI API. WAQIAuthenticationError: Used token is invalid. """ url = URL.build( scheme="https", host=self.api_host, port=443, ).joinpath(uri) headers = { "User-Agent": f"WAQIAsync/{VERSION}", "Accept": "application/json, text/plain, */*", } if self.session is None: self.session = ClientSession() self._close_session = True if data is None: # pragma: no cover data = {} data["token"] = self._token try: async with asyncio.timeout(self.request_timeout): response = await self.session.request( METH_GET, url.with_query(data), headers=headers, ) except asyncio.TimeoutError as exception: msg = "Timeout occurred while connecting to the WAQI API" raise WAQIConnectionError(msg) from exception content_type = response.headers.get("Content-Type", "") if "application/json" not in content_type: text = await response.text() msg = "Unexpected response from the WAQI API" raise WAQIError( msg, {"Content-Type": content_type, "response": text}, ) response_data = cast(dict[str, Any], await response.json()) if ( response_data["status"] == "error" and response_data["data"] == "Invalid key" ): raise WAQIAuthenticationError return response_data async def get_by_city(self, city: str) -> WAQIAirQuality: """Get air quality information for a given city.""" response = await self._request(f"feed/{city}") if response["status"] == "error" and response["data"] == "Unknown station": msg = f"Could not find city {city}" raise WAQIUnknownCityError(msg) return WAQIAirQuality.from_dict(response["data"]) async def get_by_name(self, name: str) -> WAQIAirQuality: """Get air quality measuring station by name.""" response = await self._request(f"feed/{name}") data = response["data"] if ( response["status"] == "error" and data in ("Unknown station", "no such station") ) or ( "status" in data and data["status"] == "error" and data["msg"] == "Unknown ID" ): msg = f"Could not find station {name}" raise WAQIUnknownStationError(msg) return WAQIAirQuality.from_dict(data) async def get_by_station_number(self, station_number: int) -> WAQIAirQuality: """Get air quality measuring station by station number.""" return await self.get_by_name(f"@{station_number}") async def get_by_coordinates( self, latitude: float, longitude: float, ) -> WAQIAirQuality: """Get nearest air quality measuring station by coordinates.""" response = await self._request(f"feed/geo:{latitude};{longitude}") return WAQIAirQuality.from_dict(response["data"]) async def get_by_ip( self, ) -> WAQIAirQuality: """Get the nearest air quality measuring station according to WAQI.""" return await self.get_by_name("here") async def search(self, keyword: str) -> list[WAQISearchResult]: """Search for stations with a keyword.""" response = await self._request("search/", data={"keyword": keyword}) return [WAQISearchResult.from_dict(station) for station in response["data"]] async def close(self) -> None: """Close open client session.""" if self.session and self._close_session: await self.session.close() async def __aenter__(self) -> Self: """Async enter. Returns ------- The WAQIClient object. """ return self async def __aexit__(self, *_exc_info: object) -> None: """Async exit. Args: ---- _exc_info: Exec type. """ await self.close()