diff --git a/CHANGELOG.md b/CHANGELOG.md index 9338880a..9069e404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.0.0rc3] - 2026-05-23 + +**Breaking changes** + +### Removed + +- Refactors Transport to no longer have HTTPX as dependency +- Removed AsyncClient, now only a synchronous OpenAQ client is exported + ## [1.0.0rc2] - 2026-02-24 ### Updated diff --git a/openaq/__init__.py b/openaq/__init__.py index 47302f7e..9cebf52d 100644 --- a/openaq/__init__.py +++ b/openaq/__init__.py @@ -2,16 +2,15 @@ import logging -__version__ = "1.0.0rc2" +__version__ = "1.0.0rc3" logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -from ._async.client import AsyncOpenAQ as AsyncOpenAQ -from ._sync.client import OpenAQ as OpenAQ -from .shared.exceptions import ( +from .client import OpenAQ as OpenAQ +from .core.exceptions import ( ApiKeyMissingError, BadGatewayError, BadRequestError, @@ -30,7 +29,6 @@ __all__ = [ "OpenAQ", - "AsyncOpenAQ", "IdentifierOutOfBoundsError", "ApiKeyMissingError", "NotAuthorizedError", diff --git a/openaq/_async/client.py b/openaq/_async/client.py deleted file mode 100644 index ac212edc..00000000 --- a/openaq/_async/client.py +++ /dev/null @@ -1,304 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import platform -from datetime import datetime -from types import TracebackType -from typing import Mapping - -import httpx - -from openaq import __version__ -from openaq._async.models.countries import Countries -from openaq._async.models.instruments import Instruments -from openaq._async.models.licenses import Licenses -from openaq._async.models.locations import Locations -from openaq._async.models.manufacturers import Manufacturers -from openaq._async.models.measurements import Measurements -from openaq._async.models.owners import Owners -from openaq._async.models.parameters import Parameters -from openaq._async.models.providers import Providers -from openaq._async.models.sensors import Sensors -from openaq.shared.client import DEFAULT_BASE_URL, BaseClient -from openaq.shared.exceptions import RateLimitError -from openaq.shared.transport import DEFAULT_LIMITS, DEFAULT_TIMEOUT - -from .transport import AsyncTransport - -logger = logging.getLogger(__name__) - - -class AsyncOpenAQ(BaseClient[AsyncTransport]): - """OpenAQ asynchronous client. - - Args: - api_key: The API key for accessing the service. - headers: Additional headers to be sent with the request. - auto_wait: Whether to automatically wait when rate limited. Defaults to - True. - base_url: The base URL for the API endpoint. - transport: The transport instance for making HTTP requests. For internal - use. - rate_limit_override: Override the default rate limit capacity of 60 - requests per minute. - Useful for accounts with a higher rate limit. Defaults to 60. - timeout: Timeout configuration for HTTP requests. Defaults to 5 seconds - for connection, write, and pool, and 8 seconds for read to account - for the API's 6 second processing limit. Pass None for no timeout. - limits: Connection pool limits for the HTTP transport. Defaults to 20 - maximum connections with 10 keepalive connections. Keepalive - connections expire after 30 seconds. - - Note: - An API key can either be passed directly to the OpenAQ client class at - instantiation or can be accessed from a system environment variable - name `OPENAQ_API_KEY`. An API key added at instantiation will always - override one set in the environment variable. - - Warning: - Although the `api_key` parameter is not required for instantiating the - OpenAQ client, an API Key is required for using the OpenAQ API. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when rate limit exceeded and auto_wait is False. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - - """ - - _rate_limit_capacity: float - _rate_limit_remaining: float - _in_flight_requests: int - _current_window_id: str - _sync_in_progress: bool - _lock: asyncio.Lock - _rate_limit_synced_event: asyncio.Event - - def __init__( - self, - api_key: str | None = None, - headers: Mapping[str, str] | None = None, - auto_wait: bool = True, - base_url: str = DEFAULT_BASE_URL, - transport: AsyncTransport | None = None, - timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT, - limits: httpx.Limits = DEFAULT_LIMITS, - rate_limit_override: int | None = None, - ) -> None: - if transport is None: - transport = AsyncTransport(timeout=timeout, limits=limits) - if headers is None: - headers = {} - super().__init__(transport, headers, api_key, auto_wait, base_url) - self._user_agent = ( - f"openaq-python-async-{__version__}-{platform.python_version()}" - ) - self.resolve_headers() - self.countries = Countries(self) - self.instruments = Instruments(self) - self.licenses = Licenses(self) - self.locations = Locations(self) - self.manufacturers = Manufacturers(self) - self.measurements = Measurements(self) - self.owners = Owners(self) - self.providers = Providers(self) - self.parameters = Parameters(self) - self.sensors = Sensors(self) - rate_limit = rate_limit_override if rate_limit_override is not None else 60 - self._rate_limit_capacity = float(rate_limit) - self._rate_limit_remaining = self._rate_limit_capacity - self._lock = asyncio.Lock() - self._in_flight_requests = 0 - self._current_window_id = datetime.now().strftime("%Y%m%d%H%M") - self._rate_limit_synced_event = asyncio.Event() - self._sync_in_progress = False - - @property - def transport(self) -> AsyncTransport: - return self._transport - - async def _acquire_token(self) -> None: - """Acquire a rate limit token before making a request. - - Checks available capacity against in-flight requests in the current - time window. If capacity is available, increments the in-flight counter - and returns immediately. If the window has rolled over, resets remaining - capacity accounting for any still in-flight requests from the previous - window before granting the token. - - If no capacity is available and auto_wait is enabled, sleeps until the - next window opens and then grants the token. If auto_wait is disabled, - raises RateLimitError immediately. - - Raises: - RateLimitError: If capacity is exhausted and auto_wait is False. - """ - async with self._lock: - now = datetime.now() - window_id = now.strftime("%Y%m%d%H%M") - - if self._current_window_id != window_id: - self._rate_limit_remaining = ( - self._rate_limit_capacity - self._in_flight_requests - ) - self._current_window_id = window_id - - available = self._rate_limit_remaining - self._in_flight_requests - if available >= 1.0: - self._in_flight_requests += 1 - return - - if not self._auto_wait: - raise RateLimitError("Rate limit exceeded") - - seconds_until_next_min = 60 - now.second - (now.microsecond / 1_000_000) - wait = seconds_until_next_min + 0.5 - - await asyncio.sleep(wait) - - async with self._lock: - self._rate_limit_remaining = ( - self._rate_limit_capacity - self._in_flight_requests - ) - self._current_window_id = datetime.now().strftime("%Y%m%d%H%M") - self._in_flight_requests += 1 - - def _set_rate_limit(self, headers: httpx.Headers | Mapping[str, str]) -> None: - """Synchronize local rate limit state with API provided response headers. - - Reads the x-ratelimit-remaining and x-ratelimit-limit headers from the - HTTP response and updates the local capacity and remaining token counts. - This corrects any drift between the client-side estimates and the - server's actual counts, such as at window boundaries or after bursts. - - Args: - headers: The response headers from the HTTP client. - """ - x_ratelimit_remaining_header = headers.get("x-ratelimit-remaining") - x_ratelimit_limit_header = headers.get("x-ratelimit-limit") - - try: - if x_ratelimit_limit_header is not None: - self._rate_limit_capacity = float(x_ratelimit_limit_header) - if x_ratelimit_remaining_header is not None: - self._rate_limit_remaining = float(x_ratelimit_remaining_header) - except ValueError as e: - logger.error(f"API sent malformed rate limit headers: {e}") - - async def _do( - self, - method: str, - path: str, - *, - params: ( - httpx.QueryParams | Mapping[str, str | int | float | bool] | None - ) = None, - headers: httpx.Headers | Mapping[str, str] | None = None, - ) -> httpx.Response: - """Execute an HTTP request with rate limit handling and state synchronization. - - On the first request, designates the calling coroutine as the - initial sync request. All other coroutines that arrive before the first - response is received will wait until the server has confirmed the true - rate limit state via response headers. Subsequent requests proceed - directly to token acquisition. - - Once a token is acquired, builds the request headers, constructs the - full URL, and dispatches the request via the transport layer. On - completion, synchronizes local rate limit state from the response - headers and decrements the in-flight counter. - - Args: - method: HTTP method. - path: API endpoint path. - params: Query parameters. - headers: Additional request headers. - - Returns: - HTTP response object. - - Raises: - RateLimitError: If rate limited and auto_wait is False. - """ - is_initial_request = False - if not self._rate_limit_synced_event.is_set(): - async with self._lock: - if ( - not self._rate_limit_synced_event.is_set() - and not self._sync_in_progress - ): - self._sync_in_progress = True - is_initial_request = True - - if not is_initial_request: - await self._rate_limit_synced_event.wait() - - await self._acquire_token() - - try: - request_headers = self.build_request_headers(headers) - url = self._base_url + path - data = await self.transport.send_request( - method=method, url=url, params=params, headers=request_headers - ) - self._set_rate_limit(data.headers) - return data - - finally: - async with self._lock: - self._in_flight_requests = max(0, self._in_flight_requests - 1) - if is_initial_request: - self._sync_in_progress = False - - if is_initial_request: - self._rate_limit_synced_event.set() - - async def _get( - self, - path: str, - *, - params: ( - httpx.QueryParams | Mapping[str, str | int | float | bool] | None - ) = None, - headers: httpx.Headers | Mapping[str, str] | None = None, - ) -> httpx.Response: - """Make a GET request to the API. - - Args: - path: API endpoint path. - params: Query parameters. - headers: Additional request headers. - - Returns: - HTTPX response object. - """ - return await self._do("get", path, params=params, headers=headers) - - async def close(self) -> None: - """Closes transport connection.""" - return await self.transport.close() - - async def __aenter__(self) -> AsyncOpenAQ: - """Enter the async context manager.""" - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Exit the async context manager and close the connection.""" - await self.close() diff --git a/openaq/_async/models/base.py b/openaq/_async/models/base.py deleted file mode 100644 index bc58983c..00000000 --- a/openaq/_async/models/base.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from openaq._async.client import AsyncOpenAQ - - -class AsyncResourceBase: - """Base model for async resources. - - Handles the instantiation of the parent client object. - - - Attributes: - client: an instance of OpenAQ async client object. - - """ - - def __init__( - self, - client: AsyncOpenAQ, - ): - """Initialize the SyncResourceBase. - - Args: - client (OpenAQ): The client instance to interact with the OpenAQ API. - """ - self._client = client diff --git a/openaq/_async/models/countries.py b/openaq/_async/models/countries.py deleted file mode 100644 index c83ab4ad..00000000 --- a/openaq/_async/models/countries.py +++ /dev/null @@ -1,119 +0,0 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import CountriesResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( - validate_integer_id, - validate_integer_or_list_integer_params, - validate_limit_param, - validate_order_by, - validate_page_param, - validate_sort_order, -) - -from .base import AsyncResourceBase - - -class Countries(AsyncResourceBase): - """This provides methods to retrieve country data from the OpenAQ API.""" - - async def get(self, countries_id: int) -> CountriesResponse: - """Retrieve specific country data by its countries ID. - - Args: - countries_id: The countries ID of the country to retrieve. - - Returns: - CountriesResponse: An instance representing the retrieved country. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - countries_id = validate_integer_id(countries_id) - country = await self._client._get(f"/countries/{countries_id}") - return CountriesResponse.read_response(country) - - async def list( - self, - page: int = 1, - limit: int = 1000, - order_by: str | None = None, - sort_order: SortOrder | None = None, - parameters_id: int | list[int] | None = None, - providers_id: int | list[int] | None = None, - ) -> CountriesResponse: - """List countries based on provided filters. - - Args: - page: The page number, must be greater than zero. Page count is countries found / limit. - limit: The number of results returned per page. Must be between 1 and 1,000. - order_by: Order by operators for results. - sort_order: Order for sorting results (asc/desc). - parameters_id: Single parameters ID or an array of IDs. - providers_id: Single providers ID or an array of IDs. - - Returns: - CountriesResponse: An instance representing the list of retrieved countries. - - Raises: - InvalidParameterError: Client validation error, query parameter is not correct type or value. - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - page = validate_page_param(page) - limit = validate_limit_param(limit) - if sort_order is not None: - sort_order = validate_sort_order(sort_order) - if order_by is not None: - order_by = validate_order_by(order_by) - if parameters_id is not None: - parameters_id = validate_integer_or_list_integer_params( - 'parameters_id', parameters_id - ) - if providers_id is not None: - providers_id = validate_integer_or_list_integer_params( - 'providers_id', providers_id - ) - params = build_query_params( - page=page, - limit=limit, - order_by=order_by, - sort_order=sort_order, - parameters_id=parameters_id, - providers_id=providers_id, - ) - params = build_query_params( - page=page, - limit=limit, - order_by=order_by, - sort_order=sort_order, - parameters_id=parameters_id, - providers_id=providers_id, - ) - - countries = await self._client._get("/countries", params=params) - return CountriesResponse.read_response(countries) diff --git a/openaq/_async/models/instruments.py b/openaq/_async/models/instruments.py deleted file mode 100644 index a6ca6dec..00000000 --- a/openaq/_async/models/instruments.py +++ /dev/null @@ -1,93 +0,0 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import InstrumentsResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( - validate_integer_id, - validate_limit_param, - validate_order_by, - validate_page_param, - validate_sort_order, -) - -from .base import AsyncResourceBase - - -class Instruments(AsyncResourceBase): - """This provides methods to retrieve instrument data from the OpenAQ API.""" - - async def get(self, instruments_id: int) -> InstrumentsResponse: - """Retrieve specific instrument data by its instruments ID. - - Args: - instruments_id: The instruments ID of the instrument to retrieve. - - Returns: - InstrumentsResponse: An instance representing the retrieved instrument. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - instruments_id = validate_integer_id(instruments_id) - instrument = await self._client._get(f"/instruments/{instruments_id}") - return InstrumentsResponse.read_response(instrument) - - async def list( - self, - page: int = 1, - limit: int = 1000, - order_by: str | None = None, - sort_order: SortOrder | None = None, - ) -> InstrumentsResponse: - """List instruments based on provided filters. - - Args: - page: The page number, must be greater than zero. Page count is instruments found / limit. - limit: The number of results returned per page. Must be between 1 and 1,000. - order_by: Order by operators for results. - sort_order: Order for sorting results (asc/desc). - - Returns: - InstrumentsResponse: An instance representing the list of retrieved instruments. - - Raises: - InvalidParameterError: Client validation error, query parameter is not correct type or value. - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - page = validate_page_param(page) - limit = validate_limit_param(limit) - if sort_order is not None: - sort_order = validate_sort_order(sort_order) - if order_by is not None: - order_by = validate_order_by(order_by) - params = build_query_params( - page=page, limit=limit, order_by=order_by, sort_order=sort_order - ) - - instruments = await self._client._get("/instruments", params=params) - return InstrumentsResponse.read_response(instruments) diff --git a/openaq/_async/models/licenses.py b/openaq/_async/models/licenses.py deleted file mode 100644 index 13b6a288..00000000 --- a/openaq/_async/models/licenses.py +++ /dev/null @@ -1,96 +0,0 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import LicensesResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( - validate_integer_id, - validate_limit_param, - validate_order_by, - validate_page_param, - validate_sort_order, -) - -from .base import AsyncResourceBase - - -class Licenses(AsyncResourceBase): - """This provides methods to retrieve air monitor locations resource from the OpenAQ API.""" - - async def get(self, licenses_id: int) -> LicensesResponse: - """Retrieve a specific license by its licenses ID. - - Args: - licenses_id: The licenses ID of the license to retrieve. - - Returns: - LicensesReponse: An instance representing the retrieved license. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - licenses_id = validate_integer_id(licenses_id) - license = await self._client._get(f"/licenses/{licenses_id}") - return LicensesResponse.read_response(license) - - async def list( - self, - page: int = 1, - limit: int = 1000, - order_by: str | None = None, - sort_order: SortOrder | None = None, - ) -> LicensesResponse: - """List licenses based on provided filters. - - Args: - page: The page number, must be greater than zero. Page count is licenses found / limit. - limit: The number of results returned per page. Must be between 1 and 1,000. - order_by: Order by operators for results. - sort_order: Order for sorting results (asc/desc). - - Returns: - LicensesReponse: An instance representing the list of retrieved licenses. - - Raises: - InvalidParameterError: Client validation error, query parameter is not correct type or value. - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - page = validate_page_param(page) - limit = validate_limit_param(limit) - if sort_order is not None: - sort_order = validate_sort_order(sort_order) - if order_by is not None: - order_by = validate_order_by(order_by) - params = build_query_params( - page=page, - limit=limit, - order_by=order_by, - sort_order=sort_order, - ) - - licenses = await self._client._get("/licenses", params=params) - return LicensesResponse.read_response(licenses) diff --git a/openaq/_async/models/locations.py b/openaq/_async/models/locations.py deleted file mode 100644 index 6d821eef..00000000 --- a/openaq/_async/models/locations.py +++ /dev/null @@ -1,233 +0,0 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import ( - LatestResponse, - LocationsResponse, - SensorsResponse, -) -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( - validate_countries_query_parameters, - validate_geospatial_params, - validate_integer_id, - validate_integer_or_list_integer_params, - validate_limit_param, - validate_mobile, - validate_monitor, - validate_order_by, - validate_page_param, - validate_sort_order, -) - -from .base import AsyncResourceBase - - -class Locations(AsyncResourceBase): - """This provides methods to retrieve air monitor locations resource from the OpenAQ API.""" - - async def get(self, locations_id: int) -> LocationsResponse: - """Retrieve a specific location by its locations ID. - - Args: - locations_id: The locations ID of the location to retrieve. - - Returns: - LocationsResponse: An instance representing the retrieved location. - - Raises: - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - locations_id = validate_integer_id(locations_id) - location = await self._client._get(f"/locations/{locations_id}") - return LocationsResponse.read_response(location) - - async def latest(self, locations_id: int) -> LatestResponse: - """Retrieve latest measurements from a location. - - Args: - locations_id: The locations ID of the location to retrieve. - - Returns: - LatestResponse: An instance representing the retrieved latest results. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - locations_id = validate_integer_id(locations_id) - latest = await self._client._get(f"/locations/{locations_id}/latest") - return LatestResponse.read_response(latest) - - async def list( - self, - page: int = 1, - limit: int = 100, - radius: int | None = None, - coordinates: tuple[float, float] | None = None, - bbox: tuple[float, float] | None = None, - providers_id: int | list[int] | None = None, - countries_id: int | list[int] | None = None, - parameters_id: int | list[int] | None = None, - licenses_id: int | list[int] | None = None, - instruments_id: int | list[int] | None = None, - manufacturers_id: int | list[int] | None = None, - owners_id: int | list[int] | None = None, - iso: str | None = None, - monitor: bool | None = None, - mobile: bool | None = None, - order_by: str | None = None, - sort_order: SortOrder | None = None, - ) -> LocationsResponse: - """List locations based on provided filters. - - Args: - page: Page number to retrieve, must be greater than zero. - Page count is calculated as total locations / limit. - limit: Number of results per page. Must be between 1 and 1,000. - radius: Search radius in meters around the coordinates point. - Must be between 1 and 25,000 (25km). - coordinates: WGS 84 coordinate pair as (latitude, longitude). - bbox: Geospatial bounding box as (min_x, min_y, max_x, max_y) - in WGS 84 coordinates. Limited to four decimal places. - providers_id: Filter locations by provider ID(s). - Accepts a single ID or list of IDs. - countries_id: Filter locations by country ID(s). - Accepts a single ID or list of IDs. - parameters_id: Filter locations by parameter ID(s). - Accepts a single ID or list of IDs. - licenses_id: Filter locations by license ID(s). - Accepts a single ID or list of IDs. - instruments_id: Filter locations by instrument ID(s). - Accepts a single ID or list of IDs. - manufacturers_id: Filter locations by manufacturer ID(s). - Accepts a single ID or list of IDs. - owners_id: Filter locations by owner ID(s). - Accepts a single ID or list of IDs. - iso: Filter locations by 2-letter ISO 3166-alpha-2 country code. - monitor: Filter by monitor type. True for reference grade monitors, - False for air sensors. - mobile: Filter by mobility. True for mobile locations, - False for stationary locations. - order_by: Field name to sort results by. - sort_order: Sort direction, either 'asc' or 'desc'. - - Returns: - LocationsResponse: An instance representing the list of retrieved locations. - - Raises: - InvalidParameterError: Client validation error, query parameter is not correct type or value. - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - page = validate_page_param(page) - limit = validate_limit_param(limit) - validate_geospatial_params(coordinates, radius, bbox) - countries_id, iso = validate_countries_query_parameters(countries_id, iso) - if providers_id: - providers_id = validate_integer_or_list_integer_params( - 'providers_id', providers_id - ) - if parameters_id: - parameters_id = validate_integer_or_list_integer_params( - 'parameters_id', parameters_id - ) - if licenses_id: - licenses_id = validate_integer_or_list_integer_params( - 'licenses_id', licenses_id - ) - if instruments_id: - instruments_id = validate_integer_or_list_integer_params( - 'instruments_id', instruments_id - ) - if manufacturers_id: - manufacturers_id = validate_integer_or_list_integer_params( - 'manufacturers_id', manufacturers_id - ) - if owners_id: - owners_id = validate_integer_or_list_integer_params('owners_id', owners_id) - if monitor is not None: - monitor = validate_monitor(monitor) - if mobile is not None: - mobile = validate_mobile(mobile) - if sort_order is not None: - sort_order = validate_sort_order(sort_order) - if order_by is not None: - order_by = validate_order_by(order_by) - params = build_query_params( - page=page, - limit=limit, - radius=radius, - coordinates=coordinates, - bbox=bbox, - providers_id=providers_id, - countries_id=countries_id, - parameters_id=parameters_id, - licenses_id=licenses_id, - instruments_id=instruments_id, - manufacturers_id=manufacturers_id, - owner_contacts_id=owners_id, - iso=iso, - monitor=monitor, - mobile=mobile, - order_by=order_by, - sort_order=sort_order, - ) - - locations = await self._client._get("/locations", params=params) - return LocationsResponse.read_response(locations) - - async def sensors(self, locations_id: int) -> SensorsResponse: - """Retrieve sensors from a location. - - Args: - locations_id: The locations ID of the location to retrieve. - - Returns: - SensorsResponse: An instance representing the retrieved latest results. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - locations_id = validate_integer_id(locations_id) - sensors = await self._client._get(f"/locations/{locations_id}/sensors") - return SensorsResponse.read_response(sensors) diff --git a/openaq/_async/models/manufacturers.py b/openaq/_async/models/manufacturers.py deleted file mode 100644 index 4dbb07a0..00000000 --- a/openaq/_async/models/manufacturers.py +++ /dev/null @@ -1,123 +0,0 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import InstrumentsResponse, ManufacturersResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( - validate_integer_id, - validate_limit_param, - validate_order_by, - validate_page_param, - validate_sort_order, -) - -from .base import AsyncResourceBase - - -class Manufacturers(AsyncResourceBase): - """This provides methods to retrieve manufacturer data from the OpenAQ API.""" - - async def get(self, manufacturers_id: int) -> ManufacturersResponse: - """Retrieve specific manufacturer data by its manufacturers ID. - - Args: - manufacturers_id: The manufacturers ID of the manufacturer to retrieve. - - Returns: - ManufacturersResponse: An instance representing the retrieved manufacturer. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - manufacturers_id = validate_integer_id(manufacturers_id) - manufacturer = await self._client._get(f"/manufacturers/{manufacturers_id}") - return ManufacturersResponse.read_response(manufacturer) - - async def list( - self, - page: int = 1, - limit: int = 1000, - order_by: str | None = None, - sort_order: SortOrder | None = None, - ) -> ManufacturersResponse: - """List manufacturers based on provided filters. - - Args: - page: The page number, must be greater than zero. Page count is manfacturers found / limit. - limit: The number of results returned per page. Must be between 1 and 1,000. - order_by: Order by operators for results. - sort_order: Order for sorting results (asc/desc). - - Returns: - ManufacturersResponse: An instance representing the list of retrieved manufacturers. - - Raises: - InvalidParameterError: Client validation error, query parameter is not correct type or value. - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - page = validate_page_param(page) - limit = validate_limit_param(limit) - if sort_order is not None: - sort_order = validate_sort_order(sort_order) - if order_by is not None: - order_by = validate_order_by(order_by) - params = build_query_params( - page=page, limit=limit, order_by=order_by, sort_order=sort_order - ) - manufacturers = await self._client._get("/manufacturers", params=params) - return ManufacturersResponse.read_response(manufacturers) - - async def instruments(self, manufacturers_id: int) -> InstrumentsResponse: - """Retrieve instruments of a manufacturer by ID. - - Args: - manufacturers_id: The manufacturers ID of the manufacturer to retrieve. - - Returns: - InstrumentsResponse: An instance representing the retrieved instruments. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - manufacturers_id = validate_integer_id(manufacturers_id) - instruments_response = await self._client._get( - f"/manufacturers/{manufacturers_id}/instruments" - ) - return InstrumentsResponse.read_response(instruments_response) diff --git a/openaq/_async/models/measurements.py b/openaq/_async/models/measurements.py deleted file mode 100644 index 265996e0..00000000 --- a/openaq/_async/models/measurements.py +++ /dev/null @@ -1,111 +0,0 @@ -import datetime -from typing import overload - -from openaq.shared.models import build_measurements_path, build_query_params -from openaq.shared.responses import MeasurementsResponse -from openaq.shared.types import Data, DateData, DatetimeData, Rollup -from openaq.shared.validators import ( - validate_data_rollup_compatibility, - validate_datetime_params, - validate_integer_id, - validate_limit_param, - validate_page_param, -) - -from .base import AsyncResourceBase - - -class Measurements(AsyncResourceBase): - """This provides methods to retrieve and list the air quality measurements from the OpenAQ API.""" - - @overload - async def list( - self, - sensors_id: int, - data: DatetimeData, - rollup: Rollup | None = None, - datetime_from: datetime.datetime | datetime.date | str | None = None, - datetime_to: datetime.datetime | datetime.date | str | None = None, - date_from: None = None, - date_to: None = None, - page: int = 1, - limit: int = 1000, - ) -> MeasurementsResponse: ... - - @overload - async def list( - self, - sensors_id: int, - data: DateData, - rollup: Rollup | None = None, - datetime_from: None = None, - datetime_to: None = None, - date_from: datetime.date | str | None = None, - date_to: datetime.date | str | None = None, - page: int = 1, - limit: int = 1000, - ) -> MeasurementsResponse: ... - - async def list( - self, - sensors_id: int, - data: Data, - rollup: Rollup | None = None, - datetime_from: datetime.datetime | datetime.date | str | None = None, - datetime_to: datetime.datetime | datetime.date | str | None = None, - date_from: datetime.date | str | None = None, - date_to: datetime.date | str | None = None, - page: int = 1, - limit: int = 1000, - ) -> MeasurementsResponse: - """List air quality measurements based on provided filters. - - Args: - sensors_id: The ID of the sensor for which measurements should be retrieved. - data: The base measurement unit to query - rollup: The period by which to rollup the base measurement data. - datetime_from: Starting datetime for the measurement retrieval. Can be a datetime.datetime object, datetime.date object, or ISO-8601 formatted string. Only used with data='measurements' or data='hours'. - datetime_to: Ending datetime for the measurement retrieval. Can be a datetime.datetime object, datetime.date object, or ISO-8601 formatted string. Only used with data='measurements' or data='hours'. - date_from: Starting date for the measurement retrieval. Can be a datetime.date object or ISO-8601 formatted date string. Only used with data='days' or data='years'. - date_to: Ending date for the measurement retrieval. Can be a datetime.date object or ISO-8601 formatted date string. Only used with data='days' or data='years'. - page: The page number, must be greater than zero. Page count is measurements found / limit. - limit: The number of results returned per page. Must be between 1 and 1,000. - - Returns: - MeasurementsResponse: An instance representing the list of retrieved air quality measurements. - - Raises: - InvalidParameterError: Client validation error, query parameter is not correct type or value. - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - sensors_id = validate_integer_id(sensors_id) - page = validate_page_param(page) - limit = validate_limit_param(limit) - data, rollup = validate_data_rollup_compatibility(data, rollup) - datetime_from, datetime_to, date_from, date_to = validate_datetime_params( - data, datetime_from, datetime_to, date_from, date_to - ) - params = build_query_params( - page=page, - limit=limit, - datetime_from=datetime_from, - datetime_to=datetime_to, - date_from=date_from, - date_to=date_to, - ) - path = build_measurements_path(sensors_id, data, rollup) - measurements = await self._client._get(path, params=params) - return MeasurementsResponse.read_response(measurements) diff --git a/openaq/_async/models/owners.py b/openaq/_async/models/owners.py deleted file mode 100644 index 2fb61174..00000000 --- a/openaq/_async/models/owners.py +++ /dev/null @@ -1,92 +0,0 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import OwnersResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( - validate_integer_id, - validate_limit_param, - validate_order_by, - validate_page_param, - validate_sort_order, -) - -from .base import AsyncResourceBase - - -class Owners(AsyncResourceBase): - """This provides methods to retrieve owner data from the OpenAQ API.""" - - async def get(self, owners_id: int) -> OwnersResponse: - """Retrieve specific owner data by its owners ID. - - Args: - owners_id: The owners ID of the owner to retrieve. - - Returns: - OwnersResponse: An instance representing the retrieved owner. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - owners_id = validate_integer_id(owners_id) - owner = await self._client._get(f"/owners/{owners_id}") - return OwnersResponse.read_response(owner) - - async def list( - self, - page: int = 1, - limit: int = 1000, - order_by: str | None = None, - sort_order: SortOrder | None = None, - ) -> OwnersResponse: - """List owners based on provided filters. - - Args: - page: The page number, must be greater than zero. Page count is owners found / limit. - limit: The number of results returned per page. Must be between 1 and 1,000. - order_by: Order by operators for results. - sort_order: Order for sorting results (asc/desc). - - Returns: - OwnersResponse: An instance representing the list of retrieved owners. - - Raises: - InvalidParameterError: Client validation error, query parameter is not correct type or value. - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - page = validate_page_param(page) - limit = validate_limit_param(limit) - if sort_order is not None: - sort_order = validate_sort_order(sort_order) - if order_by is not None: - order_by = validate_order_by(order_by) - params = build_query_params( - page=page, limit=limit, order_by=order_by, sort_order=sort_order - ) - owners = await self._client._get("/owners", params=params) - return OwnersResponse.read_response(owners) diff --git a/openaq/_async/models/parameters.py b/openaq/_async/models/parameters.py deleted file mode 100644 index 632a0ea3..00000000 --- a/openaq/_async/models/parameters.py +++ /dev/null @@ -1,156 +0,0 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import LatestResponse, ParametersResponse -from openaq.shared.types import ParameterType, SortOrder -from openaq.shared.validators import ( - validate_geospatial_params, - validate_integer_id, - validate_integer_or_list_integer_params, - validate_iso_param, - validate_limit_param, - validate_order_by, - validate_page_param, - validate_parameter_type, - validate_sort_order, -) - -from .base import AsyncResourceBase - - -class Parameters(AsyncResourceBase): - """This provides methods to retrieve parameter data from the OpenAQ API.""" - - async def get(self, parameters_id: int) -> ParametersResponse: - """Retrieve specific parameter data by its parameters ID. - - Args: - parameters_id: The parameters ID of the parameter to retrieve. - - Returns: - ParametersResponse: An instance representing the retrieved parameter. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - parameters_id = validate_integer_id(parameters_id) - parameter = await self._client._get(f"/parameters/{parameters_id}") - return ParametersResponse.read_response(parameter) - - async def list( - self, - page: int = 1, - limit: int = 1000, - order_by: str | None = None, - sort_order: SortOrder | None = None, - parameter_type: ParameterType | None = None, - coordinates: tuple[float, float] | None = None, - radius: int | None = None, - bbox: tuple[float, float, float, float] | None = None, - iso: str | None = None, - countries_id: int | list[int] | None = None, - ) -> ParametersResponse: - """List parameters based on provided filters. - - Args: - page: The page number, must be greater than zero. Page count is parameters found / limit. - limit: The number of results returned per page. Must be between 1 and 1,000. - parameter_type: pollutant or meteorological. - radius: A distance value in meters to search around the given coordinates value. - coordinates: WGS 84 coordinate pair in form latitude, longitude (y,x). - bbox: Geospatial bounding box of min X, min Y, max X, max Y in WGS 84 coordinates. Limited to four decimals precision. - iso: 2 letter ISO 3166-alpha-2 country code. - countries_id: Single countries ID or an array of IDs. - order_by: Order by operators for results. - sort_order: Order for sorting results (asc/desc). - - Returns: - ParametersResponse: An instance representing the list of retrieved parameters. - - Raises: - InvalidParameterError: Client validation error, query parameter is not correct type or value. - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - page = validate_page_param(page) - limit = validate_limit_param(limit) - validate_geospatial_params(coordinates, radius, bbox) - if countries_id: - countries_id = validate_integer_or_list_integer_params( - 'countries_id', countries_id - ) - if iso: - iso = validate_iso_param(iso) - if sort_order is not None: - sort_order = validate_sort_order(sort_order) - if order_by is not None: - order_by = validate_order_by(order_by) - if parameter_type is not None: - parameter_type = validate_parameter_type(parameter_type) - params = build_query_params( - page=page, - limit=limit, - order_by=order_by, - sort_order=sort_order, - parameter_type=parameter_type, - coordinates=coordinates, - radius=radius, - bbox=bbox, - iso=iso, - countries_id=countries_id, - ) - - parameters = await self._client._get("/parameters", params=params) - return ParametersResponse.read_response(parameters) - - async def latest(self, parameters_id: int) -> LatestResponse: - """Retrieve latest measurements from a location. - - Args: - parameters_id: The locations ID of the location to retrieve. - - Returns: - LatestResponse: An instance representing the retrieved latest results. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - parameters_id = validate_integer_id(parameters_id) - latest = await self._client._get(f"/parameters/{parameters_id}/latest") - return LatestResponse.read_response(latest) diff --git a/openaq/_async/models/providers.py b/openaq/_async/models/providers.py deleted file mode 100644 index 49ed460d..00000000 --- a/openaq/_async/models/providers.py +++ /dev/null @@ -1,131 +0,0 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import ProvidersResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( - validate_geospatial_params, - validate_integer_id, - validate_integer_or_list_integer_params, - validate_iso_param, - validate_limit_param, - validate_order_by, - validate_page_param, - validate_sort_order, -) - -from .base import AsyncResourceBase - - -class Providers(AsyncResourceBase): - """This provides methods to retrieve provider data from the OpenAQ API.""" - - async def get(self, providers_id: int) -> ProvidersResponse: - """Retrieve specific provider data by its providers ID. - - Args: - providers_id: The providers ID of the provider to retrieve. - - Returns: - ProvidersResponse: An instance representing the retrieved provider. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - providers_id = validate_integer_id(providers_id) - provider = await self._client._get(f"/providers/{providers_id}") - return ProvidersResponse.read_response(provider) - - async def list( - self, - page: int = 1, - limit: int = 1000, - order_by: str | None = None, - sort_order: SortOrder | None = None, - parameters_id: int | list[int] | None = None, - monitor: bool | None = None, - coordinates: tuple[float, float] | None = None, - radius: int | None = None, - bbox: tuple[float, float, float, float] | None = None, - iso: str | None = None, - countries_id: int | list[int] | None = None, - ) -> ProvidersResponse: - """List providers based on provided filters. - - Args: - page: The page number, must be greater than zero. Page count is providers found / limit. - limit: The number of results returned per page. Must be between 1 and 1,000. - parameters_id: Single parameters ID or an array of IDs. - monitor: Boolean for reference grade monitors (true) or air sensors (false). - radius: A distance value in meters to search around the given coordinates value, must be between 1 and 25,000 (25km). - coordinates: WGS 84 coordinate pair in form latitude, longitude (y,x). - bbox: Geospatial bounding box of min X, min Y, max X, max Y in WGS 84 coordinates. Limited to four decimals precision. - iso: 2 letter ISO 3166-alpha-2 country code. - countries_id: Single countries ID or an array of IDs. - order_by: Order by operators for results. - sort_order: Order for sorting results (asc/desc). - - Returns: - ProvidersResponse: An instance representing the list of retrieved providers. - - Raises: - InvalidParameterError: Client validation error, query parameter is not correct type or value. - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - page = validate_page_param(page) - limit = validate_limit_param(limit) - validate_geospatial_params(coordinates, radius, bbox) - if countries_id: - countries_id = validate_integer_or_list_integer_params( - 'countries_id', countries_id - ) - if parameters_id: - parameters_id = validate_integer_or_list_integer_params( - 'parameters_id', parameters_id - ) - if iso: - iso = validate_iso_param(iso) - if sort_order is not None: - sort_order = validate_sort_order(sort_order) - if order_by is not None: - order_by = validate_order_by(order_by) - params = build_query_params( - page=page, - limit=limit, - order_by=order_by, - sort_order=sort_order, - parameters_id=parameters_id, - monitor=monitor, - coordinates=coordinates, - radius=radius, - bbox=bbox, - iso=iso, - countries_id=countries_id, - ) - - providers = await self._client._get("/providers", params=params) - return ProvidersResponse.read_response(providers) diff --git a/openaq/_async/models/sensors.py b/openaq/_async/models/sensors.py deleted file mode 100644 index 79ad8662..00000000 --- a/openaq/_async/models/sensors.py +++ /dev/null @@ -1,37 +0,0 @@ -from openaq.shared.responses import SensorsResponse -from openaq.shared.validators import validate_integer_id - -from .base import AsyncResourceBase - - -class Sensors(AsyncResourceBase): - """This provides methods to retrieve sensor data from the OpenAQ API.""" - - async def get(self, sensors_id: int) -> SensorsResponse: - """Retrieve specific sensor data by its sensors ID. - - Args: - sensors_id: The sensors ID of the sensor to retrieve. - - Returns: - SensorsResponse: An instance representing the retrieved sensor. - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when managed client exceeds rate limit. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - sensors_id = validate_integer_id(sensors_id) - sensor_response = await self._client._get(f"/sensors/{sensors_id}") - return SensorsResponse.read_response(sensor_response) diff --git a/openaq/_async/transport.py b/openaq/_async/transport.py deleted file mode 100644 index b680ae68..00000000 --- a/openaq/_async/transport.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging -from typing import Mapping - -import httpx - -from ..shared.transport import DEFAULT_LIMITS, DEFAULT_TIMEOUT, check_response - -logger = logging.getLogger(__name__) - - -class AsyncTransport: - def __init__( - self, - timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT, - limits: httpx.Limits = DEFAULT_LIMITS, - ) -> None: - self.client = httpx.AsyncClient(timeout=timeout, limits=limits) - - async def send_request( - self, - method: str, - url: str, - params: httpx.QueryParams | Mapping[str, str | int | float | bool] | None, - headers: httpx.Headers | Mapping[str, str], - ) -> httpx.Response: - request = httpx.Request( - method=method, - url=url, - params=params, - headers=headers, - ) - logger.debug( - f"Sending request to: {request.url}", - extra={ - 'method': method, - 'url': str(request.url), - 'params': params, - }, - ) - res = await self.client.send(request) - logger.debug(f"Received response: {res.status_code} from {request.url}") - return check_response(res) - - async def close(self) -> None: - await self.client.aclose() diff --git a/openaq/_sync/__init__.py b/openaq/_sync/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openaq/_sync/client.py b/openaq/_sync/client.py deleted file mode 100644 index 7210ed19..00000000 --- a/openaq/_sync/client.py +++ /dev/null @@ -1,244 +0,0 @@ -from __future__ import annotations - -import logging -import platform -import time -from datetime import datetime, timedelta -from types import TracebackType -from typing import Mapping - -import httpx - -from openaq import __version__ -from openaq._sync.models.countries import Countries -from openaq._sync.models.instruments import Instruments -from openaq._sync.models.licenses import Licenses -from openaq._sync.models.locations import Locations -from openaq._sync.models.manufacturers import Manufacturers -from openaq._sync.models.measurements import Measurements -from openaq._sync.models.owners import Owners -from openaq._sync.models.parameters import Parameters -from openaq._sync.models.providers import Providers -from openaq._sync.models.sensors import Sensors -from openaq.shared.client import DEFAULT_BASE_URL, BaseClient -from openaq.shared.exceptions import RateLimitError -from openaq.shared.transport import DEFAULT_LIMITS, DEFAULT_TIMEOUT - -from .transport import Transport - -logger = logging.getLogger(__name__) - - -class OpenAQ(BaseClient[Transport]): - """OpenAQ synchronous client. - - Args: - api_key: The API key for accessing the service. - headers: Additional headers to be sent with the request. - auto_wait: Whether to automatically wait when rate limited. Defaults to - True. - base_url: The base URL for the API endpoint. - transport: The transport instance for making HTTP requests. For internal - use. - rate_limit_override: Override the default rate limit capacity of 60 - requests per minute. - Useful for accounts with a higher rate limit. Defaults to 60. - timeout: Timeout configuration for HTTP requests. Defaults to 5 seconds - for connection, write, and pool, and 8 seconds for read to account - for the API's 6 second processing limit. Pass None for no timeout. - limits: Connection pool limits for the HTTP transport. Defaults to 20 - maximum connections with 10 keepalive connections. Keepalive - connections expire after 30 seconds. - - Note: - An API key can either be passed directly to the OpenAQ client class at - instantiation or can be accessed from a system environment variable - named `OPENAQ_API_KEY`. An API key added at instantiation will always - override one set in the environment variable. - - Warning: - Although the `api_key` parameter is not required for instantiating the - OpenAQ client, an API Key is required for using the OpenAQ API. - - - Raises: - IdentifierOutOfBoundsError: Client validation error, identifier outside support int32 range. - ApiKeyMissingError: Authentication error, missing API Key credentials. - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - RateLimitError: Raised when rate limit exceeded and auto_wait is False. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - - """ - - _rate_limit_reset_datetime: datetime - _rate_limit_remaining: float - _rate_limit_capacity: float - _current_window_id: str - - def __init__( - self, - api_key: str | None = None, - headers: Mapping[str, str] | None = None, - auto_wait: bool = True, - base_url: str = DEFAULT_BASE_URL, - transport: Transport | None = None, - timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT, - limits: httpx.Limits = DEFAULT_LIMITS, - rate_limit_override: int | None = None, - ) -> None: - if transport is None: - transport = Transport(timeout=timeout, limits=limits) - if headers is None: - headers = {} - super().__init__(transport, headers, api_key, auto_wait, base_url) - self._user_agent = ( - f"openaq-python-sync-{__version__}-{platform.python_version()}" - ) - self.resolve_headers() - rate_limit = rate_limit_override if rate_limit_override is not None else 60 - self._rate_limit_capacity = float(rate_limit) - self._rate_limit_reset_datetime = datetime.min - self._rate_limit_remaining = self._rate_limit_capacity - self._current_window_id = datetime.now().strftime("%Y%m%d%H%M") - - self.countries = Countries(self) - self.instruments = Instruments(self) - self.licenses = Licenses(self) - self.locations = Locations(self) - self.manufacturers = Manufacturers(self) - self.measurements = Measurements(self) - self.owners = Owners(self) - self.providers = Providers(self) - self.parameters = Parameters(self) - self.sensors = Sensors(self) - - @property - def _rate_limit_reset_seconds(self) -> int: - return int((self._rate_limit_reset_datetime - datetime.now()).total_seconds()) - - def _is_rate_limited(self) -> bool: - return ( - self._rate_limit_remaining == 0 - and self._rate_limit_reset_datetime > datetime.now() - ) - - def _check_rate_limit(self) -> None: - now = datetime.now() - window_id = now.strftime("%Y%m%d%H%M") - - if self._current_window_id != window_id: - self._rate_limit_remaining = self._rate_limit_capacity - self._current_window_id = window_id - return - - if self._rate_limit_remaining <= 0: - if self._auto_wait: - self._wait_for_rate_limit_reset() - self._rate_limit_remaining = self._rate_limit_capacity - self._current_window_id = datetime.now().strftime("%Y%m%d%H%M") - else: - message = f"Rate limit exceeded. Limit resets in {self._rate_limit_reset_seconds} seconds" - logger.error(message) - raise RateLimitError(message) - - def _set_rate_limit(self, headers: httpx.Headers) -> None: - rate_limit_remaining = self._get_int_header(headers, 'x-ratelimit-remaining', 0) - rate_limit_reset_seconds = self._get_int_header( - headers, 'x-ratelimit-reset', 60 - ) - now = (datetime.now() + timedelta(seconds=0.5)).replace(microsecond=0) - rate_limit_reset_datetime = now + timedelta(seconds=rate_limit_reset_seconds) - self._rate_limit_remaining = rate_limit_remaining - self._rate_limit_reset_datetime = rate_limit_reset_datetime - - def _wait_for_rate_limit_reset(self) -> None: - """Wait until the rate limit resets.""" - wait_seconds = self._rate_limit_reset_seconds - if wait_seconds > 0: - logger.info(f"Rate limit hit. Waiting {wait_seconds} seconds for reset.") - time.sleep(wait_seconds) - - def _do( - self, - method: str, - path: str, - *, - params: ( - httpx.QueryParams | Mapping[str, str | int | float | bool] | None - ) = None, - headers: httpx.Headers | Mapping[str, str] | None = None, - ) -> httpx.Response: - """Execute an HTTP request with rate limit handling. - - Checks rate limits before making the request. If auto_wait is enabled - and rate limited, waits for the limit to reset. Otherwise raises - RateLimitError if rate limited. - - Args: - method: HTTP method (get, post, etc.). - path: API endpoint path. - params: Query parameters. - headers: Additional request headers. - - Returns: - HTTP response object. - - Raises: - RateLimitError: If rate limited and auto_wait is False. - """ - self._check_rate_limit() - self._rate_limit_remaining -= 1 - request_headers = self.build_request_headers(headers) - url = self._base_url + path - data = self.transport.send_request( - method=method, url=url, params=params, headers=request_headers - ) - self._set_rate_limit(data.headers) - return data - - def _get( - self, - path: str, - *, - params: ( - httpx.QueryParams | Mapping[str, str | int | float | bool] | None - ) = None, - headers: httpx.Headers | Mapping[str, str] | None = None, - ) -> httpx.Response: - """Make a GET request to the API. - - Args: - path: API endpoint path. - params: Query parameters. - headers: HTTP request headers. - - Returns: - HTTP response object. - """ - return self._do("get", path, params=params, headers=headers) - - def close(self) -> None: - """Close the transport connection.""" - self.transport.close() - - def __enter__(self) -> OpenAQ: - """Enter the context manager.""" - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Exit the context manager and close the connection.""" - self.close() diff --git a/openaq/_sync/models/__init__.py b/openaq/_sync/models/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openaq/_sync/transport.py b/openaq/_sync/transport.py deleted file mode 100644 index a3ee92ea..00000000 --- a/openaq/_sync/transport.py +++ /dev/null @@ -1,50 +0,0 @@ -import logging -from typing import Mapping - -import httpx - -from openaq.shared.transport import ( - DEFAULT_LIMITS, - DEFAULT_TIMEOUT, - check_response, -) - -logger = logging.getLogger(__name__) - - -class Transport: - def __init__( - self, - timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT, - limits: httpx.Limits = DEFAULT_LIMITS, - ) -> None: - self.client = httpx.Client(timeout=timeout, limits=limits) - - def send_request( - self, - method: str, - url: str, - params: httpx.QueryParams | Mapping[str, str | int | float | bool] | None, - headers: httpx.Headers | Mapping[str, str], - ) -> httpx.Response: - """Sends an HTTP request using the provided method, URL, parameters, and headers.""" - request = httpx.Request( - method=method, - url=url, - params=params, - headers=headers, - ) - logger.debug( - f"Sending request to: {request.url}", - extra={ - 'method': method, - 'url': str(request.url), - 'params': params, - }, - ) - res = self.client.send(request) - logger.debug(f"Received response: {res.status_code} from {request.url}") - return check_response(res) - - def close(self) -> None: - self.client.close() diff --git a/openaq/client.py b/openaq/client.py new file mode 100644 index 00000000..4598eaeb --- /dev/null +++ b/openaq/client.py @@ -0,0 +1,393 @@ +"""OpenAQ API client for interacting with the OpenAQ REST API. + +Provides the OpenAQ client class which manages authentication, connection +pooling, and rate limiting for requests to the OpenAQ API. +""" + +from __future__ import annotations + +import logging +import os +import platform +import time +from datetime import datetime, timedelta +from pathlib import Path +from types import TracebackType +from typing import Mapping + +from openaq import __version__ +from openaq.core.exceptions import ApiKeyMissingError, RateLimitError +from openaq.core.transport import ( + DEFAULT_LIMITS, + DEFAULT_TIMEOUT, + Headers, + Limits, + Response, + Timeout, + Transport, +) +from openaq.models.countries import Countries +from openaq.models.instruments import Instruments +from openaq.models.licenses import Licenses +from openaq.models.locations import Locations +from openaq.models.manufacturers import Manufacturers +from openaq.models.measurements import Measurements +from openaq.models.owners import Owners +from openaq.models.parameters import Parameters +from openaq.models.providers import Providers +from openaq.models.sensors import Sensors + +logger = logging.getLogger(__name__) + +ACCEPT_HEADER = "application/json" +DEFAULT_BASE_URL = "https://api.openaq.org/v3/" + +# for Python versions <3.11 tomllib is not part of std. library +_has_toml = True +try: + import tomllib +except ImportError: + _has_toml = False + + +def _get_openaq_config() -> dict | None: + """Read api_key from ~/.openaq.toml if present.""" + config_path = Path.home() / ".openaq.toml" + if config_path.is_file(): + with open(config_path, "rb") as f: + if _has_toml: + raw = tomllib.load(f) + api_key = raw.get("api-key") + if isinstance(api_key, str): + return {"api_key": api_key} + return None + + +def _resolve_api_key(api_key: str | None) -> str | None: + """Return api_key from argument, environment, or config file — in that order.""" + if api_key: + return api_key + if env := os.environ.get("OPENAQ_API_KEY"): + return env + if config := _get_openaq_config(): + return config["api_key"] + return None + + +class OpenAQ: + """OpenAQ API client. + + Args: + api_key: The API key for accessing the service. + headers: Additional headers to be sent with the request. + auto_wait: Whether to automatically wait when rate limited. Defaults to + True. + base_url: The base URL for the API endpoint. + transport: The transport instance for making HTTP requests. For internal + use. + rate_limit_override: Override the default rate limit capacity of 60 + requests per minute. Useful for accounts with a higher rate limit. + Defaults to None. + timeout: Timeout configuration for HTTP requests. Defaults to 5 seconds + for connection and pool, and 8 seconds for read to account for the + API's 6 second processing limit. Pass None for no timeout. + limits: Connection pool limits for the HTTP transport. Defaults to 20 + maximum connections with 10 keepalive connections. Keepalive + connections expire after 30 seconds. + + Note: + An API key can either be passed directly to the OpenAQ client class at + instantiation or can be accessed from a system environment variable + named `OPENAQ_API_KEY`. An API key added at instantiation will always + override one set in the environment variable. + + Warning: + Although the `api_key` parameter is not required for instantiating the + OpenAQ client, an API Key is required for using the OpenAQ API. + + Raises: + ApiKeyMissingError: Authentication error, missing API Key credentials. + BadRequestError: Raised for HTTP 400 error, indicating a client request error. + NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. + ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. + NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. + TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. + ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. + RateLimitError: Raised when rate limit exceeded and auto_wait is False. + HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. + ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. + BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. + ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. + GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. + """ + + _rate_limit_reset_datetime: datetime + _rate_limit_remaining: float + _rate_limit_capacity: float + + def __init__( + self, + api_key: str | None = None, + headers: Mapping[str, str] | None = None, + auto_wait: bool = True, + base_url: str = DEFAULT_BASE_URL, + transport: Transport | None = None, + timeout: float | Timeout | None = DEFAULT_TIMEOUT, + limits: Limits = DEFAULT_LIMITS, + rate_limit_override: int | None = None, + ) -> None: + """Initializes the OpenAQ client. + + Args: + api_key: The API key for accessing the service. If not provided, + the client will attempt to resolve it from the OPENAQ_API_KEY + environment variable or ~/.openaq.toml config file. + headers: Additional headers to be sent with every request. + auto_wait: Whether to automatically wait when rate limited. Defaults + to True. + base_url: The base URL for the API endpoint. + transport: The transport instance for making HTTP requests. For + internal use. + timeout: Timeout configuration for HTTP requests. Defaults to 5 + seconds for connection and 8 seconds for read. Pass None for no + timeout. + limits: Connection pool limits for the HTTP transport. Defaults to + 20 maximum connections with 10 keepalive connections expiring + after 30 seconds. + rate_limit_override: Initial rate limit capacity in requests per + minute. Defaults to 60 and is corrected automatically from + server response headers after the first request. + + Raises: + ApiKeyMissingError: If no API key is provided and the default base + URL is used. + """ + self._api_key = _resolve_api_key(api_key) + self._base_url = base_url + self._auto_wait = auto_wait + self._transport = ( + transport + if transport is not None + else Transport(timeout=timeout, limits=limits) + ) + self._headers = Headers(headers or {}) + + if not self._api_key and self._base_url == DEFAULT_BASE_URL: + logger.error( + "API key not set: An API key is required when using the OpenAQ API" + ) + raise ApiKeyMissingError( + "API key not set: An API key is required when using the OpenAQ API" + ) + + self._user_agent = f"openaq-python-{__version__}-{platform.python_version()}" + assert self._api_key is not None + self._headers["X-API-Key"] = self._api_key + self._headers["User-Agent"] = self._user_agent + self._headers["Accept"] = ACCEPT_HEADER + + # assumes default until corrected by the first response + rate_limit = rate_limit_override if rate_limit_override is not None else 60 + self._rate_limit_capacity = float(rate_limit) + self._rate_limit_reset_datetime = datetime.min + self._rate_limit_remaining = self._rate_limit_capacity + + self.countries = Countries(self) + self.instruments = Instruments(self) + self.licenses = Licenses(self) + self.locations = Locations(self) + self.manufacturers = Manufacturers(self) + self.measurements = Measurements(self) + self.owners = Owners(self) + self.providers = Providers(self) + self.parameters = Parameters(self) + self.sensors = Sensors(self) + + @property + def api_key(self) -> str | None: + """The API key used to authenticate requests.""" + return self._api_key + + @property + def transport(self) -> Transport: + """The transport instance used to send HTTP requests.""" + return self._transport + + @property + def headers(self) -> Headers: + """The default headers sent with every request.""" + return self._headers + + @property + def base_url(self) -> str: + """The base URL for the API.""" + return self._base_url + + @property + def _rate_limit_reset_seconds(self) -> int: + """Seconds remaining until the rate limit window resets.""" + return int((self._rate_limit_reset_datetime - datetime.now()).total_seconds()) + + def _is_rate_limited(self) -> bool: + """Returns True if the rate limit is exhausted and the reset time has not yet passed.""" + return ( + self._rate_limit_remaining <= 0 + and self._rate_limit_reset_datetime > datetime.now() + ) + + def _check_rate_limit(self) -> None: + """Checks the current rate limit state before sending a request. + + If the rate limit is exhausted and the reset time has not passed, + either waits for the reset if auto_wait is True, or raises + RateLimitError if auto_wait is False. Decrements the remaining + count by one after passing the check. + + Raises: + RateLimitError: If rate limited and auto_wait is False. + """ + if self._is_rate_limited(): + if self._auto_wait: + self._wait_for_rate_limit_reset() + self._rate_limit_remaining = self._rate_limit_capacity + else: + message = f"Rate limit exceeded. Limit resets in {self._rate_limit_reset_seconds} seconds" + logger.error(message) + raise RateLimitError(message) + self._rate_limit_remaining -= 1 + + def _set_rate_limit(self, headers: Headers) -> None: + """Updates rate limit state from response headers. + + Reads x-ratelimit-limit, x-ratelimit-remaining, and x-ratelimit-reset + from the response headers to keep the client's local rate limit state + in sync with the server. + + Args: + headers: The response headers from a completed request. + """ + rate_limit_capacity = self._get_int_header(headers, "x-ratelimit-limit", 60) + rate_limit_remaining = self._get_int_header(headers, "x-ratelimit-remaining", 0) + rate_limit_reset_seconds = self._get_int_header( + headers, "x-ratelimit-reset", 60 + ) + now = (datetime.now() + timedelta(seconds=0.5)).replace(microsecond=0) + self._rate_limit_capacity = float(rate_limit_capacity) + self._rate_limit_remaining = rate_limit_remaining + self._rate_limit_reset_datetime = now + timedelta( + seconds=rate_limit_reset_seconds + ) + + def _wait_for_rate_limit_reset(self) -> None: + """Blocks until the rate limit window resets. + + If the reset time is in the future, sleeps for the remaining duration. + Returns immediately if the reset time has already passed. + """ + wait_seconds = self._rate_limit_reset_seconds + if wait_seconds > 0: + logger.info(f"Rate limit hit. Waiting {wait_seconds} seconds for reset.") + time.sleep(wait_seconds) + + def _get_int_header(self, headers: Headers, key: str, default: int) -> int: + """Reads an integer value from response headers with a fallback default. + + Args: + headers: The response headers to read from. + key: The header name to look up. + default: The value to return if the header is missing or not a + valid integer. + + Returns: + The integer value of the header, or default if not present or invalid. + """ + try: + value = headers.get(key) + if value is None: + return default + return int(value) + except ValueError: + return default + + def _build_request_headers(self, headers: Mapping[str, str] | None) -> Headers: + """Merges per-request headers with the client's default headers. + + Args: + headers: Optional additional headers for this request. If None, + a copy of the default headers is returned. + + Returns: + A Headers instance combining default and per-request headers. + """ + if headers: + request_headers = self._headers.copy() + request_headers.update(headers) + return request_headers + return self._headers.copy() + + def _do( + self, + method: str, + path: str, + *, + params: Mapping[str, str | int | float | bool] | None = None, + headers: Headers | Mapping[str, str] | None = None, + ) -> Response: + """Execute an HTTP request with rate limit handling. + + Args: + method: HTTP method (get, post, etc.). + path: API endpoint path. + params: Query parameters. + headers: Additional request headers. + + Returns: + HTTP response object. + + Raises: + RateLimitError: If rate limited and auto_wait is False. + """ + self._check_rate_limit() + request_headers = self._build_request_headers(headers) + url = self._base_url + path + data = self._transport.send_request( + method=method, url=url, params=params, headers=request_headers + ) + self._set_rate_limit(data.headers) + return data + + def _get( + self, + path: str, + *, + params: Mapping[str, str | int | float | bool] | None = None, + headers: Headers | Mapping[str, str] | None = None, + ) -> Response: + """Make a GET request to the API. + + Args: + path: API endpoint path. + params: Query parameters. + headers: HTTP request headers. + + Returns: + HTTP response object. + """ + return self._do("get", path, params=params, headers=headers) + + def close(self) -> None: + """Closes the transport and releases all pooled connections.""" + self._transport.close() + + def __enter__(self) -> OpenAQ: + """Enters the context manager, returning the client instance.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exits the context manager, closing the transport.""" + self.close() diff --git a/openaq/_async/__init__.py b/openaq/core/__init__.py similarity index 100% rename from openaq/_async/__init__.py rename to openaq/core/__init__.py diff --git a/openaq/shared/constants.py b/openaq/core/constants.py similarity index 100% rename from openaq/shared/constants.py rename to openaq/core/constants.py diff --git a/openaq/shared/exceptions.py b/openaq/core/exceptions.py similarity index 100% rename from openaq/shared/exceptions.py rename to openaq/core/exceptions.py diff --git a/openaq/shared/models.py b/openaq/core/models.py similarity index 100% rename from openaq/shared/models.py rename to openaq/core/models.py diff --git a/openaq/shared/responses.py b/openaq/core/responses.py similarity index 96% rename from openaq/shared/responses.py rename to openaq/core/responses.py index 373e49e4..681986d6 100644 --- a/openaq/shared/responses.py +++ b/openaq/core/responses.py @@ -8,8 +8,7 @@ from types import ModuleType from typing import Any, Generic, TypeVar, cast, get_args -import httpx - +from openaq.core.transport import Response from openaq.vendor.humps import camelize, decamelize try: @@ -17,12 +16,12 @@ except ImportError: orjson = None # type: ignore[assignment] -T = TypeVar("T", bound="_ResourceBase") +T = TypeVar("T", bound="_ModelBase") _DECAMELIZE_CACHE: dict[str, str] = {} -class _ResourceBase: +class _ModelBase: """Base clase for all response classes. Handles serialization and deserialization of JSON data and setting of @@ -98,7 +97,7 @@ def __post_init__(self) -> None: @dataclass(slots=True) -class Meta(_ResourceBase): +class Meta(_ModelBase): """API response metadata. Attributes: @@ -138,7 +137,7 @@ class attributes results: list[TResult] @classmethod - def read_response(cls: type[R], response: httpx.Response) -> R: + def read_response(cls: type[R], response: Response) -> R: valid_headers = [field.name for field in fields(Headers)] json_data = response.json() return cls( @@ -210,7 +209,7 @@ def json(self, encoder: ModuleType = json) -> str: @dataclass(slots=True) -class CountryBase(_ResourceBase): +class CountryBase(_ModelBase): """Base representation for country resource in OpenAQ. Attributes: @@ -225,7 +224,7 @@ class CountryBase(_ResourceBase): @dataclass(slots=True) -class InstrumentBase(_ResourceBase): +class InstrumentBase(_ModelBase): """Base representation for instrument resource in OpenAQ. Attributes: @@ -238,7 +237,7 @@ class InstrumentBase(_ResourceBase): @dataclass(slots=True) -class ManufacturerBase(_ResourceBase): +class ManufacturerBase(_ModelBase): """Base representation for manufacturer resource in OpenAQ. Attributes: @@ -251,7 +250,7 @@ class ManufacturerBase(_ResourceBase): @dataclass(slots=True) -class OwnerBase(_ResourceBase): +class OwnerBase(_ModelBase): """Base representation for owner resource in OpenAQ. Attributes: @@ -264,7 +263,7 @@ class OwnerBase(_ResourceBase): @dataclass(slots=True) -class ParameterBase(_ResourceBase): +class ParameterBase(_ModelBase): """Base representation for measurement parameter resource in OpenAQ. Attributes: @@ -279,7 +278,7 @@ class ParameterBase(_ResourceBase): @dataclass(slots=True) -class ProviderBase(_ResourceBase): +class ProviderBase(_ModelBase): """Base representation for providers in OpenAQ. Attributes: @@ -292,7 +291,7 @@ class ProviderBase(_ResourceBase): @dataclass(slots=True) -class SensorBase(_ResourceBase): +class SensorBase(_ModelBase): """Base representation for sensor resource in OpenAQ. Attributes: @@ -312,7 +311,7 @@ def __post_init__(self) -> None: @dataclass(slots=True) -class Coordinates(_ResourceBase): +class Coordinates(_ModelBase): """Representation for geographic coordinates in OpenAQ. coordinates are represented in WGS84 (AKA EPSG 4326) coordinate system. @@ -327,7 +326,7 @@ class Coordinates(_ResourceBase): @dataclass(slots=True) -class Datetime(_ResourceBase): +class Datetime(_ModelBase): """Representation for timestamps in OpenAQ. Attributes: @@ -340,7 +339,7 @@ class Datetime(_ResourceBase): @dataclass(slots=True) -class Location(_ResourceBase): +class Location(_ModelBase): """Representation of location resource in OpenAQ. Attributes: @@ -425,7 +424,7 @@ class LocationsResponse(_ResponseBase[Location]): @dataclass(slots=True) -class OwnerEntity(_ResourceBase): +class OwnerEntity(_ModelBase): """Representation of owner entitiy resource in OpenAQ. Attributes: @@ -438,7 +437,7 @@ class OwnerEntity(_ResourceBase): @dataclass(slots=True) -class Bbox(_ResourceBase): +class Bbox(_ModelBase): """Bounding box representation for geographic areas. Attributes: @@ -455,7 +454,7 @@ class Bbox(_ResourceBase): @dataclass(slots=True) -class Provider(_ResourceBase): +class Provider(_ModelBase): """Representation of provider resource in OpenAQ. id: unique identifier for provider @@ -507,7 +506,7 @@ class ProvidersResponse(_ResponseBase[Provider]): @dataclass(slots=True) -class Parameter(_ResourceBase): +class Parameter(_ModelBase): """Representation of parameter resource in OpenAQ. Attributes: @@ -541,7 +540,7 @@ class ParametersResponse(_ResponseBase[Parameter]): @dataclass(slots=True) -class Country(_ResourceBase): +class Country(_ModelBase): """Representation of country resource in OpenAQ. Attributes: @@ -584,7 +583,7 @@ class CountriesResponse(_ResponseBase[Country]): @dataclass(slots=True) -class Instrument(_ResourceBase): +class Instrument(_ModelBase): """Representation of instrument resource in OpenAQ. Attributes: @@ -621,7 +620,7 @@ class InstrumentsResponse(_ResponseBase[Instrument]): @dataclass(slots=True) -class License(_ResourceBase): +class License(_ModelBase): """Representation of license resource in OpenAQ. Attributes: @@ -661,7 +660,7 @@ class LicensesResponse(_ResponseBase[License]): @dataclass(slots=True) -class Manufacturer(_ResourceBase): +class Manufacturer(_ModelBase): """Representation of manufacturer resource in OpenAQ. Attributes: @@ -697,7 +696,7 @@ class ManufacturersResponse(_ResponseBase[Manufacturer]): @dataclass(slots=True) -class Summary(_ResourceBase): +class Summary(_ModelBase): """Statistical summary of measurement values. Attributes: @@ -723,7 +722,7 @@ class Summary(_ResourceBase): @dataclass(slots=True) -class Coverage(_ResourceBase): +class Coverage(_ModelBase): """Data coverage details for measurements. Attributes: @@ -755,7 +754,7 @@ def __post_init__(self) -> None: @dataclass(slots=True) -class Period(_ResourceBase): +class Period(_ModelBase): """Representation of a measurement time period. Attributes: @@ -780,7 +779,7 @@ def __post_init__(self) -> None: @dataclass(slots=True) -class Measurement(_ResourceBase): +class Measurement(_ModelBase): """Representation of measurement resource in OpenAQ. Attributes: @@ -829,7 +828,7 @@ class MeasurementsResponse(_ResponseBase[Measurement]): @dataclass(slots=True) -class Owner(_ResourceBase): +class Owner(_ModelBase): """Detailed information about an owner in OpenAQ. Attributes: @@ -857,7 +856,7 @@ class OwnersResponse(_ResponseBase[Owner]): @dataclass(slots=True) -class LatestBase(_ResourceBase): +class LatestBase(_ModelBase): """latest measurement. Attributes: @@ -879,7 +878,7 @@ def __post_init__(self) -> None: @dataclass(slots=True) -class Sensor(_ResourceBase): +class Sensor(_ModelBase): """Detailed information about a sensor in OpenAQ. Attributes: @@ -931,7 +930,7 @@ class SensorsResponse(_ResponseBase[Sensor]): @dataclass(slots=True) -class Latest(_ResourceBase): +class Latest(_ModelBase): """Latest measurement. Attributes: diff --git a/openaq/core/transport.py b/openaq/core/transport.py new file mode 100644 index 00000000..2009ef81 --- /dev/null +++ b/openaq/core/transport.py @@ -0,0 +1,569 @@ +"""Base class and utlity functions for working with client transport.""" + +from __future__ import annotations + +import http.client +import json +import logging +import threading +import time +import urllib.parse +from collections import deque +from dataclasses import dataclass, field +from http import HTTPStatus +from typing import Any, Mapping + +from openaq.core.exceptions import ( + BadGatewayError, + BadRequestError, + ForbiddenError, + GatewayTimeoutError, + HTTPRateLimitError, + NotAuthorizedError, + NotFoundError, + ServerError, + ServiceUnavailableError, + TimeoutError, + ValidationError, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class Timeout: + """Configuration for request timeout values. + + Attributes: + timeout: Default timeout in seconds applied to all values unless + overridden. + connect: Timeout in seconds for establishing a connection. Defaults to + the value of ``timeout`` if not explicitly set. + read: Timeout in seconds for reading a response. + pool: Timeout in seconds to wait for a connection to become available + from the connection pool. + """ + + timeout: float | None = 5.0 + connect: float | None = None + read: float | None = 8.0 + pool: float | None = None + + def __post_init__(self) -> None: + """Sets connect timeout to the default timeout value if not explicitly provided.""" + if self.connect is None: + self.connect = self.timeout + + +@dataclass(kw_only=True) +class Limits: + """Configuration for connection pool size and keep-alive behavior. + + Attributes: + max_connections: Maximum number of connections allowed in the pool + across all hosts. + max_keepalive_connections: Maximum number of idle keep-alive connections + per host. + keepalive_expiry: Duration in seconds after which an idle connection is + considered expired and discarded. + """ + + max_connections: int = 20 + max_keepalive_connections: int = 10 + keepalive_expiry: float = 30.0 + + +class Headers(dict): + """A case-insensitive dictionary for HTTP headers. + + Keys are normalized to lowercase on insertion and lookup, ensuring that + header comparisons are case-insensitive as required by the HTTP spec. + """ + + def __init__(self, data: Mapping[str, str] | None = None) -> None: + """Initializes Headers, optionally pre-populated from a mapping. + + Args: + data: An optional mapping of header name-value pairs. + """ + super().__init__() + if data: + for k, v in data.items(): + self[k] = v + + def __setitem__(self, key: str, value: str) -> None: + """Sets a header, normalizing the key to lowercase.""" + super().__setitem__(key.lower(), value) + + def __getitem__(self, key: str) -> str: + """Gets a header by key, normalizing the key to lowercase.""" + return super().__getitem__(key.lower()) + + def __contains__(self, key: object) -> bool: + """Checks if a header exists, normalizing the key to lowercase.""" + return super().__contains__(str(key).lower()) + + def get(self, key: str, default: str | None = None) -> str | None: # type: ignore[override] + """Gets a header by key, normalizing the key to lowercase.""" + return super().get(key.lower(), default) + + def update(self, other: Mapping[str, str] | None = None, **kwargs: str) -> None: # type: ignore[override] + """Updates headers from a mapping or keyword arguments, normalizing keys to lowercase.""" + if other: + for k, v in other.items(): + self[k] = v + for k, v in kwargs.items(): + self[k] = v + + def copy(self) -> Headers: + """Returns a shallow copy of this Headers instance. + + Returns: + A new Headers instance with the same key-value pairs. + """ + h = Headers() + super(Headers, h).update(self) + return h + + +class Response: + """Represents an HTTP response returned from the server. + + Attributes: + status_code: The HTTP status code of the response. + """ + + def __init__( + self, + status_code: int, + raw_bytes: bytes, + headers: http.client.HTTPMessage, + ) -> None: + """Initializes a Response. + + Args: + status_code: The HTTP status code. + raw_bytes: The raw response body as bytes. + headers: The HTTP headers returned with the response. + """ + self.status_code = status_code + self._raw_bytes = raw_bytes + self._http_headers = headers + + @property + def text(self) -> str: + """Decodes and returns the response body as a string. + + Uses the charset specified in the Content-Type header, falling back to + UTF-8. Invalid byte sequences are replaced rather than raising an error. + + Returns: + The response body as a decoded string. + """ + charset = str(self._http_headers.get_param("charset") or "utf-8") + try: + return self._raw_bytes.decode(charset) + except (LookupError, UnicodeDecodeError): + return self._raw_bytes.decode("utf-8", errors="replace") + + @property + def headers(self) -> Headers: + """Returns the response headers as a case-insensitive Headers instance. + + Returns: + A Headers instance containing the response headers. + """ + return Headers(dict(self._http_headers)) + + def json(self) -> Any: + """Parses and returns the response body as JSON. + + Returns: + The deserialized JSON content. + """ + return json.loads(self._raw_bytes) + + +DEFAULT_TIMEOUT = Timeout(5.0, read=8.0) +DEFAULT_LIMITS = Limits( + max_connections=20, + max_keepalive_connections=10, + keepalive_expiry=30.0, +) + + +@dataclass(slots=True) +class PooledConnection: + """A pooled HTTPS connection with metadata for lifecycle management. + + Attributes: + host: The hostname of the connection. + connect_timeout: Timeout in seconds used when establishing the + connection. + last_used: Monotonic timestamp of when the connection was last returned + to the pool, used to determine keep-alive expiry. + conn: The underlying HTTPS connection object. + """ + + host: str + connect_timeout: float | None + last_used: float = field(default_factory=time.monotonic) + conn: http.client.HTTPSConnection = field(init=False) + + def __post_init__(self) -> None: + """Initializes the underlying HTTPS connection after the dataclass is created.""" + self.conn = http.client.HTTPSConnection(self.host, timeout=self.connect_timeout) + + +class ConnectionPool: + """A thread-safe pool of reusable HTTPS connections. + + Manages a bounded set of connections across hosts, reusing idle connections + where possible and blocking callers when the pool is at capacity. + """ + + def __init__(self, limits: Limits, connect_timeout: float | None) -> None: + """Initializes the ConnectionPool. + + Args: + limits: Pool size and keep-alive configuration. + connect_timeout: Timeout in seconds for establishing new + connections. + """ + self._max_total = limits.max_connections + self._max_idle = limits.max_keepalive_connections + self._expiry = limits.keepalive_expiry + self._connect_timeout = connect_timeout + + self._lock = threading.Lock() + self._has_capacity = threading.Condition(self._lock) + self._idle: dict[str, deque[PooledConnection]] = {} + self._total: int = 0 + + def _evict_expired(self, host: str) -> None: + """Removes expired idle connections for a given host. + + Must be called while holding ``self._lock``. + + Args: + host: The hostname whose idle connections should be checked for + expiry. + """ + q = self._idle.get(host) + if not q: + return + now = time.monotonic() + while q and (now - q[0].last_used) > self._expiry: + pc = q.popleft() + try: + pc.conn.close() + except Exception: + pass + self._total -= 1 + + def acquire(self, host: str, pool_timeout: float | None = None) -> PooledConnection: + """Checks out a connection for the given host. + + Reuses an idle connection if one is available, or creates a new one if + the pool has capacity. Blocks until a connection becomes available or the + timeout is exceeded. + + Args: + host: The hostname to acquire a connection for. + pool_timeout: Maximum number of seconds to wait for a connection to + become available. Blocks indefinitely if ``None``. + + Returns: + A PooledConnection ready for use. + + Raises: + TimeoutError: If no connection becomes available within ``pool_timeout`` seconds. + """ + deadline = (time.monotonic() + pool_timeout) if pool_timeout else None + + with self._has_capacity: + while True: + self._evict_expired(host) + q = self._idle.get(host) + if q: + pc = q.pop() + return pc + if self._total < self._max_total: + self._total += 1 + return PooledConnection(host, self._connect_timeout) + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"Connection pool exhausted for {host!r}: " + f"no slot available within {pool_timeout}s" + ) + self._has_capacity.wait(timeout=remaining) + else: + self._has_capacity.wait() + + def release(self, pc: PooledConnection, *, discard: bool = False) -> None: + """Returns a connection to the pool after use. + + If ``discard`` is True, or the idle queue for the host is full, the + connection is closed and removed from the total count instead. + + Args: + pc: The PooledConnection to release. + discard: If True, closes and discards the connection rather than + returning it to the idle queue. + """ + with self._has_capacity: + if discard: + try: + pc.conn.close() + except Exception: + pass + self._total -= 1 + self._has_capacity.notify_all() + return + q = self._idle.setdefault(pc.host, deque()) + if len(q) < self._max_idle: + pc.last_used = time.monotonic() + q.append(pc) + else: + try: + pc.conn.close() + except Exception: + pass + self._total -= 1 + self._has_capacity.notify_all() + + def close_all(self) -> None: + """Closes all idle connections in the pool and resets its state.""" + with self._lock: + for q in self._idle.values(): + for pc in q: + try: + pc.conn.close() + except Exception: + pass + self._idle.clear() + self._total = 0 + + +def _encode_params( + params: Mapping[str, str | int | float | bool] | None, +) -> str: + """Encodes a mapping of query parameters into a URL query string. + + Boolean values are serialized as lowercase strings (``true``/``false``) + to match common API conventions. + + Args: + params: A mapping of parameter names to values, or ``None``. + + Returns: + A percent-encoded query string, or an empty string if ``params`` is + ``None`` or empty. + """ + if not params: + return "" + return urllib.parse.urlencode( + {k: str(v).lower() if isinstance(v, bool) else v for k, v in params.items()} + ) + + +class Transport: + """Handles sending HTTP requests over a managed connection pool. + + Wraps a ConnectionPool to provide a request interface with configurable + timeouts, automatic retry on stale connections, and response validation. + """ + + def __init__( + self, + timeout: float | Timeout | None = DEFAULT_TIMEOUT, + limits: Limits = DEFAULT_LIMITS, + ) -> None: + """Initializes the Transport. + + Args: + timeout: Timeout configuration for connect, read, and pool wait + durations. Accepts a Timeout instance, a single float applied to + all phases, or ``None`` to disable timeouts entirely. + limits: Connection pool size and keep-alive configuration. + """ + if isinstance(timeout, Timeout): + self._connect_timeout = timeout.connect + self._read_timeout = timeout.read + self._pool_timeout = timeout.pool + elif isinstance(timeout, (int, float)): + self._connect_timeout = float(timeout) + self._read_timeout = float(timeout) + self._pool_timeout = None + else: + self._connect_timeout = None + self._read_timeout = None + self._pool_timeout = None + + self._pool = ConnectionPool(limits, self._connect_timeout) + + def _raw_request( + self, + method: str, + host: str, + path: str, + headers: Mapping[str, str], + ) -> Response: + """Sends a raw HTTP request, retrying once on a stale connection. + + Attempts the request up to twice. If the first attempt fails due to a + stale connection, the connection is discarded and the request is + retried. If the second attempt fails, the exception is raised to the + caller. + + Args: + method: The HTTP method (e.g. ``'GET'``, ``'POST'``). + host: The target hostname. + path: The request path, including any query string. + headers: A mapping of HTTP headers to include with the request. + + Returns: + A Response containing the status code, headers, and body. + + Raises: + OSError: If the connection fails on both attempts. + http.client.HTTPException: If an HTTP protocol error occurs on both attempts. + """ + for attempt in range(2): + pc = self._pool.acquire(host, self._pool_timeout) + try: + if self._read_timeout is not None: + if pc.conn.sock is not None: + pc.conn.sock.settimeout(self._read_timeout) + + pc.conn.request(method, path, headers=dict(headers)) + raw = pc.conn.getresponse() + + # After connect, set socket timeout for the read. + if self._read_timeout is not None and pc.conn.sock is not None: + pc.conn.sock.settimeout(self._read_timeout) + + body = raw.read() + resp = Response(raw.status, body, raw.msg) + self._pool.release(pc) + return resp + except (OSError, http.client.HTTPException) as exc: + self._pool.release(pc, discard=True) + if attempt == 1: + raise + logger.debug("Stale connection, retrying: %s", exc) + + raise RuntimeError("unreachable") # pragma: no cover + + def send_request( + self, + method: str, + url: str, + params: Mapping[str, str | int | float | bool] | None, + headers: Headers | Mapping[str, str], + ) -> Response: + """Builds and sends an HTTP request, returning a validated response. + + Appends encoded query parameters to the URL if provided, then dispatches + the request through the connection pool. + + Args: + method: The HTTP method (e.g. ``'GET'``, ``'POST'``). + url: The fully qualified request URL. + params: Optional query parameters to append to the URL. + headers: HTTP headers to include with the request. + + Returns: + A validated Response object. + """ + qs = _encode_params(params) + if qs: + separator = "&" if "?" in url else "?" + url = f"{url}{separator}{qs}" + + logger.debug( + "Sending request to: %s", + url, + extra={"method": method, "url": url, "params": params}, + ) + + parsed = urllib.parse.urlparse(url) + host = parsed.netloc + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + + res = self._raw_request(method, host, path, headers) + logger.debug("Received response: %s from %s", res.status_code, url) + return check_response(res) + + def close(self) -> None: + """Closes all pooled connections and releases pool resources.""" + self._pool.close_all() + + +def check_response(res: Response) -> Response: + """Checks the HTTP response of the request. + + Args: + res: a Response object + + Returns: + Response + + Raises: + BadRequestError: Raised for HTTP 400 error, indicating a client request error. + NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. + ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. + NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. + TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. + ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. + HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. + ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. + BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. + ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. + GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. + """ + if res.status_code >= HTTPStatus.OK and res.status_code < HTTPStatus.BAD_REQUEST: + return res + elif res.status_code == HTTPStatus.BAD_REQUEST: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise BadRequestError(res.text) + elif res.status_code == HTTPStatus.NOT_FOUND: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise NotFoundError(res.text) + elif res.status_code == HTTPStatus.REQUEST_TIMEOUT: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise TimeoutError(res.text) + elif res.status_code == HTTPStatus.FORBIDDEN: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise ForbiddenError(res.text) + elif res.status_code == HTTPStatus.UNPROCESSABLE_ENTITY: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise ValidationError(res.text) + elif res.status_code == HTTPStatus.TOO_MANY_REQUESTS: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise HTTPRateLimitError(res.text) + elif res.status_code == HTTPStatus.UNAUTHORIZED: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise NotAuthorizedError(res.text) + elif res.status_code == HTTPStatus.INTERNAL_SERVER_ERROR: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise ServerError(res.text) + elif res.status_code == HTTPStatus.BAD_GATEWAY: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise BadGatewayError(res.text) + elif res.status_code == HTTPStatus.SERVICE_UNAVAILABLE: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise ServiceUnavailableError(res.text) + elif res.status_code == HTTPStatus.GATEWAY_TIMEOUT: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise GatewayTimeoutError( + "Your request timed out on the server. " + "Consider reducing the complexity of your request." + ) + else: + logger.exception(f"HTTP {res.status_code} - {res.text}") + raise Exception diff --git a/openaq/shared/types.py b/openaq/core/types.py similarity index 100% rename from openaq/shared/types.py rename to openaq/core/types.py diff --git a/openaq/shared/validators.py b/openaq/core/validators.py similarity index 98% rename from openaq/shared/validators.py rename to openaq/core/validators.py index 3f8315f2..e0caab32 100644 --- a/openaq/shared/validators.py +++ b/openaq/core/validators.py @@ -3,12 +3,12 @@ import datetime from typing import TypeGuard, cast -from openaq.shared.constants import ISO_CODES, MAX_LIMIT -from openaq.shared.exceptions import ( +from openaq.core.constants import ISO_CODES, MAX_LIMIT +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.types import ( +from openaq.core.types import ( _DATA_VALUES, _PARAMETER_TYPE_VALUES, _ROLLUP_VALUES, @@ -635,8 +635,8 @@ def check_valid_date_parameter( data: Data, date_from: datetime.date | str | None, date_to: datetime.date | str | None, - datetime_from: datetime.datetime | datetime.date | str | None, - datetime_to: datetime.datetime | datetime.date | str | None, + datetime_from: datetime.datetime | str | None, + datetime_to: datetime.datetime | str | None, ) -> bool: """Validate data and date/datetime query parameters and their compatibility. @@ -817,8 +817,8 @@ def datetime_date_params_exclusivity_check( def validate_datetime_params( data: Data, - datetime_from: datetime.datetime | datetime.date | str | None, - datetime_to: datetime.datetime | datetime.date | str | None, + datetime_from: datetime.datetime | str | None, + datetime_to: datetime.datetime | str | None, date_from: datetime.date | str | None, date_to: datetime.date | str | None, ) -> tuple[ diff --git a/openaq/_async/models/__init__.py b/openaq/models/__init__.py similarity index 100% rename from openaq/_async/models/__init__.py rename to openaq/models/__init__.py diff --git a/openaq/_sync/models/base.py b/openaq/models/base.py similarity index 80% rename from openaq/_sync/models/base.py rename to openaq/models/base.py index 5db2a075..259a4aca 100644 --- a/openaq/_sync/models/base.py +++ b/openaq/models/base.py @@ -3,10 +3,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from openaq._sync.client import OpenAQ + from openaq.client import OpenAQ -class SyncResourceBase: +class ResourceBase: """Base model for sync resources. Handles the instantiation of the parent client object. @@ -21,7 +21,7 @@ def __init__( self, client: OpenAQ, ): - """Initialize the AsyncResourceBase. + """Initialize the ResourceBase. Args: client (OpenAQ): The client instance to interact with the OpenAQ API. diff --git a/openaq/_sync/models/countries.py b/openaq/models/countries.py similarity index 95% rename from openaq/_sync/models/countries.py rename to openaq/models/countries.py index dc4f329d..f7c7340e 100644 --- a/openaq/_sync/models/countries.py +++ b/openaq/models/countries.py @@ -1,7 +1,7 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import CountriesResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( +from openaq.core.models import build_query_params +from openaq.core.responses import CountriesResponse +from openaq.core.types import SortOrder +from openaq.core.validators import ( validate_integer_id, validate_integer_or_list_integer_params, validate_limit_param, @@ -10,10 +10,10 @@ validate_sort_order, ) -from .base import SyncResourceBase +from .base import ResourceBase -class Countries(SyncResourceBase): +class Countries(ResourceBase): """Provides methods to retrieve the country resource from the OpenAQ API.""" def get(self, countries_id: int) -> CountriesResponse: diff --git a/openaq/_sync/models/instruments.py b/openaq/models/instruments.py similarity index 94% rename from openaq/_sync/models/instruments.py rename to openaq/models/instruments.py index 349fc169..8c95cbb1 100644 --- a/openaq/_sync/models/instruments.py +++ b/openaq/models/instruments.py @@ -1,7 +1,7 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import InstrumentsResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( +from openaq.core.models import build_query_params +from openaq.core.responses import InstrumentsResponse +from openaq.core.types import SortOrder +from openaq.core.validators import ( validate_integer_id, validate_limit_param, validate_order_by, @@ -9,10 +9,10 @@ validate_sort_order, ) -from .base import SyncResourceBase +from .base import ResourceBase -class Instruments(SyncResourceBase): +class Instruments(ResourceBase): """Provides methods to retrieve the instrument resource from the OpenAQ API.""" def get(self, instruments_id: int) -> InstrumentsResponse: diff --git a/openaq/_sync/models/licenses.py b/openaq/models/licenses.py similarity index 94% rename from openaq/_sync/models/licenses.py rename to openaq/models/licenses.py index a7d0a735..78d1f678 100644 --- a/openaq/_sync/models/licenses.py +++ b/openaq/models/licenses.py @@ -1,7 +1,7 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import LicensesResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( +from openaq.core.models import build_query_params +from openaq.core.responses import LicensesResponse +from openaq.core.types import SortOrder +from openaq.core.validators import ( validate_integer_id, validate_limit_param, validate_order_by, @@ -9,10 +9,10 @@ validate_sort_order, ) -from .base import SyncResourceBase +from .base import ResourceBase -class Licenses(SyncResourceBase): +class Licenses(ResourceBase): """Provides methods to retrieve the license resource from the OpenAQ API.""" def get(self, licenses_id: int) -> LicensesResponse: diff --git a/openaq/_sync/models/locations.py b/openaq/models/locations.py similarity index 98% rename from openaq/_sync/models/locations.py rename to openaq/models/locations.py index 57c7c617..6558a666 100644 --- a/openaq/_sync/models/locations.py +++ b/openaq/models/locations.py @@ -1,11 +1,11 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import ( +from openaq.core.models import build_query_params +from openaq.core.responses import ( LatestResponse, LocationsResponse, SensorsResponse, ) -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( +from openaq.core.types import SortOrder +from openaq.core.validators import ( validate_countries_query_parameters, validate_geospatial_params, validate_integer_id, @@ -18,10 +18,10 @@ validate_sort_order, ) -from .base import SyncResourceBase +from .base import ResourceBase -class Locations(SyncResourceBase): +class Locations(ResourceBase): """Provides methods to retrieve the locations resource from the OpenAQ API.""" def get(self, locations_id: int) -> LocationsResponse: diff --git a/openaq/_sync/models/manufacturers.py b/openaq/models/manufacturers.py similarity index 96% rename from openaq/_sync/models/manufacturers.py rename to openaq/models/manufacturers.py index 220236e1..86261002 100644 --- a/openaq/_sync/models/manufacturers.py +++ b/openaq/models/manufacturers.py @@ -1,7 +1,7 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import InstrumentsResponse, ManufacturersResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( +from openaq.core.models import build_query_params +from openaq.core.responses import InstrumentsResponse, ManufacturersResponse +from openaq.core.types import SortOrder +from openaq.core.validators import ( validate_integer_id, validate_limit_param, validate_order_by, @@ -9,10 +9,10 @@ validate_sort_order, ) -from .base import SyncResourceBase +from .base import ResourceBase -class Manufacturers(SyncResourceBase): +class Manufacturers(ResourceBase): """Provides methods to retrieve the manufacturer resource from the OpenAQ API.""" def get(self, manufacturers_id: int) -> ManufacturersResponse: diff --git a/openaq/_sync/models/measurements.py b/openaq/models/measurements.py similarity index 83% rename from openaq/_sync/models/measurements.py rename to openaq/models/measurements.py index 34bd941f..751e731b 100644 --- a/openaq/_sync/models/measurements.py +++ b/openaq/models/measurements.py @@ -1,10 +1,10 @@ import datetime from typing import overload -from openaq.shared.models import build_measurements_path, build_query_params -from openaq.shared.responses import MeasurementsResponse -from openaq.shared.types import Data, DateData, DatetimeData, Rollup -from openaq.shared.validators import ( +from openaq.core.models import build_measurements_path, build_query_params +from openaq.core.responses import MeasurementsResponse +from openaq.core.types import Data, DateData, DatetimeData, Rollup +from openaq.core.validators import ( validate_data_rollup_compatibility, validate_datetime_params, validate_integer_id, @@ -12,10 +12,10 @@ validate_page_param, ) -from .base import SyncResourceBase +from .base import ResourceBase -class Measurements(SyncResourceBase): +class Measurements(ResourceBase): """Provides methods to retrieve the measurements resource from the OpenAQ API.""" @overload @@ -24,8 +24,8 @@ def list( sensors_id: int, data: DatetimeData, rollup: Rollup | None = None, - datetime_from: datetime.datetime | datetime.date | str | None = None, - datetime_to: datetime.datetime | datetime.date | str | None = None, + datetime_from: datetime.datetime | str | None = None, + datetime_to: datetime.datetime | str | None = None, date_from: None = None, date_to: None = None, page: int = 1, @@ -51,8 +51,8 @@ def list( sensors_id: int, data: Data, rollup: Rollup | None = None, - datetime_from: datetime.datetime | datetime.date | str | None = None, - datetime_to: datetime.datetime | datetime.date | str | None = None, + datetime_from: datetime.datetime | str | None = None, + datetime_to: datetime.datetime | str | None = None, date_from: datetime.date | str | None = None, date_to: datetime.date | str | None = None, page: int = 1, @@ -64,8 +64,8 @@ def list( sensors_id: The ID of the sensor for which measurements should be retrieved. data: The base measurement unit to query rollup: The period by which to rollup the base measurement data. - datetime_from: Starting datetime for the measurement retrieval. Can be a datetime.datetime object, datetime.date object, or ISO-8601 formatted string. Only used with data='measurements' or data='hours'. - datetime_to: Ending datetime for the measurement retrieval. Can be a datetime.datetime object, datetime.date object, or ISO-8601 formatted string. Only used with data='measurements' or data='hours'. + datetime_from: Starting datetime for the measurement retrieval. Can be a datetime.datetime object, or ISO-8601 formatted string. Only used with data='measurements' or data='hours'. + datetime_to: Ending datetime for the measurement retrieval. Can be a datetime.datetime object, or ISO-8601 formatted string. Only used with data='measurements' or data='hours'. date_from: Starting date for the measurement retrieval. Can be a datetime.date object or ISO-8601 formatted date string. Only used with data='days' or data='years'. date_to: Ending date for the measurement retrieval. Can be a datetime.date object or ISO-8601 formatted date string. Only used with data='days' or data='years'. page: The page number, must be greater than zero. Page count is measurements found / limit. diff --git a/openaq/_sync/models/owners.py b/openaq/models/owners.py similarity index 95% rename from openaq/_sync/models/owners.py rename to openaq/models/owners.py index 1cdf6375..be41ca53 100644 --- a/openaq/_sync/models/owners.py +++ b/openaq/models/owners.py @@ -1,7 +1,7 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import OwnersResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( +from openaq.core.models import build_query_params +from openaq.core.responses import OwnersResponse +from openaq.core.types import SortOrder +from openaq.core.validators import ( validate_integer_id, validate_limit_param, validate_order_by, @@ -9,10 +9,10 @@ validate_sort_order, ) -from .base import SyncResourceBase +from .base import ResourceBase -class Owners(SyncResourceBase): +class Owners(ResourceBase): """Provides methods to retrieve the owner resource from the OpenAQ API.""" def get(self, owners_id: int) -> OwnersResponse: diff --git a/openaq/_sync/models/parameters.py b/openaq/models/parameters.py similarity index 96% rename from openaq/_sync/models/parameters.py rename to openaq/models/parameters.py index 0be877cd..0d19613f 100644 --- a/openaq/_sync/models/parameters.py +++ b/openaq/models/parameters.py @@ -1,7 +1,7 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import LatestResponse, ParametersResponse -from openaq.shared.types import ParameterType, SortOrder -from openaq.shared.validators import ( +from openaq.core.models import build_query_params +from openaq.core.responses import LatestResponse, ParametersResponse +from openaq.core.types import ParameterType, SortOrder +from openaq.core.validators import ( validate_geospatial_params, validate_integer_id, validate_integer_or_list_integer_params, @@ -13,10 +13,10 @@ validate_sort_order, ) -from .base import SyncResourceBase +from .base import ResourceBase -class Parameters(SyncResourceBase): +class Parameters(ResourceBase): """Provides methods to retrieve the parameter resource from the OpenAQ API.""" def get(self, parameters_id: int) -> ParametersResponse: diff --git a/openaq/_sync/models/providers.py b/openaq/models/providers.py similarity index 96% rename from openaq/_sync/models/providers.py rename to openaq/models/providers.py index bf7f2c0b..ea3dc7e3 100644 --- a/openaq/_sync/models/providers.py +++ b/openaq/models/providers.py @@ -1,7 +1,7 @@ -from openaq.shared.models import build_query_params -from openaq.shared.responses import ProvidersResponse -from openaq.shared.types import SortOrder -from openaq.shared.validators import ( +from openaq.core.models import build_query_params +from openaq.core.responses import ProvidersResponse +from openaq.core.types import SortOrder +from openaq.core.validators import ( validate_geospatial_params, validate_integer_id, validate_integer_or_list_integer_params, @@ -12,10 +12,10 @@ validate_sort_order, ) -from .base import SyncResourceBase +from .base import ResourceBase -class Providers(SyncResourceBase): +class Providers(ResourceBase): """Provides methods to retrieve provider resource from the OpenAQ API.""" def get(self, providers_id: int) -> ProvidersResponse: diff --git a/openaq/_sync/models/sensors.py b/openaq/models/sensors.py similarity index 91% rename from openaq/_sync/models/sensors.py rename to openaq/models/sensors.py index 15f00cdf..7f59f435 100644 --- a/openaq/_sync/models/sensors.py +++ b/openaq/models/sensors.py @@ -1,10 +1,10 @@ -from openaq.shared.responses import SensorsResponse -from openaq.shared.validators import validate_integer_id +from openaq.core.responses import SensorsResponse +from openaq.core.validators import validate_integer_id -from .base import SyncResourceBase +from .base import ResourceBase -class Sensors(SyncResourceBase): +class Sensors(ResourceBase): """Provides methods to retrieve the sensor resource from the OpenAQ API.""" def get(self, sensors_id: int) -> SensorsResponse: diff --git a/openaq/shared/__init__.py b/openaq/shared/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openaq/shared/client.py b/openaq/shared/client.py deleted file mode 100644 index 20c301bb..00000000 --- a/openaq/shared/client.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Base class and utilities to for shared client code.""" - -from __future__ import annotations - -import logging -import os -from abc import ABC, abstractmethod -from pathlib import Path -from typing import Generic, Mapping, TypeVar - -import httpx - -from openaq._async.transport import AsyncTransport -from openaq._sync.transport import Transport -from openaq.shared.exceptions import ApiKeyMissingError -from openaq.shared.types import OpenAQConfig - -logger = logging.getLogger(__name__) - -# for Python versions <3.11 tomllib is not part of std. library -_has_toml = True -try: - import tomllib -except ImportError: - _has_toml = False - - -ACCEPT_HEADER = "application/json" - -DEFAULT_BASE_URL = "https://api.openaq.org/v3/" - -TTransport = TypeVar('TTransport', Transport, AsyncTransport) - - -class BaseClient(ABC, Generic[TTransport]): - """Abstract class for OpenAQ clients. - - This class provides the basic structure and attributes for OpenAQ clients. It includes methods to - interact with the OpenAQ API and handle HTTP headers. - - Attributes: - _headers: The HTTP headers to include in requests. - _transport: The transport instance to make requests. - _api_key: API key - _user_agent: User-Agent HTTP header - _auto_wait: Whether the client should automatically wait when rate - limited instead of raising an exception. - _base_url: The base URL of the OpenAQ API. - - Args: - transport: The transport mechanism used for making requests to the OpenAQ API. - headers: mapping of HTTP headers to be sent with request. - api_key: OpenAQ API key string. - auto_wait: Whether to automatically wait when rate limited. Defaults to True. - base_url: The base URL for the OpenAQ API. Defaults to "https://api.openaq.org/v3/". - """ - - _headers: httpx.Headers - _api_key: str | None - _base_url: str - _user_agent: str - _auto_wait: bool - _transport: TTransport - - def __init__( - self, - transport: TTransport, - headers: Mapping[str, str] = {}, - api_key: str | None = None, - auto_wait: bool = True, - base_url: str = "https://api.openaq.org/v3/", - ) -> None: - """Initialize a new instance of BaseClient. - - Args: - transport: The transport mechanism used for making requests to the OpenAQ API. - headers: mapping of HTTP headers to be sent with request. - api_key: OpenAQ API key string. - auto_wait: defaults to True. - base_url: The base URL for the OpenAQ API. Defaults to "https://api.openaq.org/v3/". - """ - if api_key: - self._api_key = api_key - else: - self._api_key = self._get_api_key() - self._headers = httpx.Headers(headers) - self._transport: TTransport = transport - self._base_url = base_url - self._auto_wait = auto_wait - self._check_api_key_url() - - def _check_api_key_url(self) -> None: - if not self.api_key and self.base_url == DEFAULT_BASE_URL: - logger.error( - "API key not set: An API key is required when using the OpenAQ API" - ) - raise ApiKeyMissingError( - "API key not set: An API key is required when using the OpenAQ API" - ) - - def _get_api_key(self) -> str | None: - """Gets API key value from env or openaq config file. - - Returns: - The API key value set either in the `OPENAQ_API_KEY` environment - variable or the `api-key` value in the .openaq.toml configuration - file. A value passed to the `api_key` parameter in the class - constructor will always override these other values. the - `OPENAQ_API_KEY` environment variable get second priority with - the configuration file `api-key` having last priority. - """ - if os.environ.get("OPENAQ_API_KEY", None): - return os.environ.get("OPENAQ_API_KEY") - config = _get_openaq_config() - if config: - return config['api_key'] - return None - - @property - def api_key(self) -> str | None: - """Accessor for private _api_key field. - - Returns: - The API key string. - """ - return self._api_key - - @property - def transport(self) -> TTransport: - """Get the transport mechanism used by the client. - - Provides access to the transport instance that the client uses to - communicate with the OpenAQ API. - - Returns: - The transport instance. - """ - return self._transport - - @property - def headers(self) -> httpx.Headers: - """Accessor for private _headers field. - - Returns: - dictionary of http headers to be sent with request - """ - return self._headers - - def build_request_headers( - self, headers: Mapping[str, str] | None = None - ) -> httpx.Headers: - """Copies and updates headers based on input. - - Args: - headers: The headers to add to the request. - - Returns: - A mapping of headers for the request - """ - if headers: - request_headers = httpx.Headers(self._headers) - request_headers.update(headers) - return request_headers - else: - return self._headers.copy() - - @property - def base_url(self) -> str: - """Accessor for private _base_url field. - - Returns: - base URL string value - - """ - return self._base_url - - def resolve_headers(self) -> None: - """Resolves and updates the HTTP headers with the given API key and User Agent. - - Args: - headers: The initial headers. - api_key: The OpenAQ API key to be added as X-API-Key header. - user_agent: The User-Agent header to be added. - - Returns: - Updated headers with the added API key and User Agent. - """ - if self.api_key is not None: - self._headers["X-API-Key"] = self.api_key - self._headers["User-Agent"] = self._user_agent - self._headers["Accept"] = ACCEPT_HEADER - - def _get_int_header(self, headers: httpx.Headers, key: str, default: int) -> int: - """Extract integer from header, avoiding Any types. - - Args: - headers: HTTP headers - key: Header key - default: Default integer value - - Returns: - Integer value from header. - """ - try: - value = headers[key] - return int(value) - except (KeyError, ValueError): - return default - - @abstractmethod - def _set_rate_limit(self, headers: httpx.Headers) -> None: ... - - -def _get_openaq_config() -> OpenAQConfig | None: - """Reads .openaq.toml configuration file. - - Depends on tomllib so only available in Python >3.11. - - """ - config_path = Path(Path.home() / ".openaq.toml") - if config_path.is_file(): - with open(config_path, 'rb') as f: - if _has_toml: - raw_config = tomllib.load(f) - config: OpenAQConfig = {} - api_key_value = raw_config.get('api-key') - if isinstance(api_key_value, str): - config['api_key'] = api_key_value - - return config if config else None - else: - return None - return None diff --git a/openaq/shared/transport.py b/openaq/shared/transport.py deleted file mode 100644 index 420e6926..00000000 --- a/openaq/shared/transport.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Base class and utlity functions for working with client transport.""" - -from __future__ import annotations - -import logging -from http import HTTPStatus - -import httpx - -from openaq.shared.exceptions import ( - BadGatewayError, - BadRequestError, - ForbiddenError, - GatewayTimeoutError, - HTTPRateLimitError, - NotAuthorizedError, - NotFoundError, - ServerError, - ServiceUnavailableError, - TimeoutError, - ValidationError, -) - -logger = logging.getLogger(__name__) - -# Set connect, write and pool to 5, and read to 8 since server times out at 6 anyways. -DEFAULT_TIMEOUT = httpx.Timeout(5.0, read=8.0) - -# connection pool sized to the 60 req/min API rate limit -DEFAULT_LIMITS = httpx.Limits( - max_connections=20, - max_keepalive_connections=10, - keepalive_expiry=30.0, -) - - -def check_response(res: httpx.Response) -> httpx.Response: - """Checks the HTTP response of the request. - - Args: - res: an httpx.Response object - - Returns: - httpx.Response - - Raises: - BadRequestError: Raised for HTTP 400 error, indicating a client request error. - NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. - ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. - NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. - TimeoutError: Raised for HTTP 408 error, indicating the request has timed out. - ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. - HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. - ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. - BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. - ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. - GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. - """ - if res.status_code >= HTTPStatus.OK and res.status_code < HTTPStatus.BAD_REQUEST: - return res - elif res.status_code == HTTPStatus.BAD_REQUEST: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise BadRequestError(res.text) - elif res.status_code == HTTPStatus.NOT_FOUND: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise NotFoundError(res.text) - elif res.status_code == HTTPStatus.REQUEST_TIMEOUT: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise TimeoutError(res.text) - elif res.status_code == HTTPStatus.FORBIDDEN: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise ForbiddenError(res.text) - elif res.status_code == HTTPStatus.UNPROCESSABLE_ENTITY: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise ValidationError(res.text) - elif res.status_code == HTTPStatus.TOO_MANY_REQUESTS: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise HTTPRateLimitError(res.text) - elif res.status_code == HTTPStatus.UNAUTHORIZED: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise NotAuthorizedError(res.text) - elif res.status_code == HTTPStatus.INTERNAL_SERVER_ERROR: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise ServerError(res.text) - elif res.status_code == HTTPStatus.BAD_GATEWAY: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise BadGatewayError(res.text) - elif res.status_code == HTTPStatus.SERVICE_UNAVAILABLE: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise ServiceUnavailableError(res.text) - elif res.status_code == HTTPStatus.GATEWAY_TIMEOUT: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise GatewayTimeoutError( - "Your request timed out on the server. " - "Consider reducing the complexity of your request." - ) - else: - logger.exception(f"HTTP {res.status_code} - {res.text}") - raise Exception diff --git a/pyproject.toml b/pyproject.toml index 527a554e..7dfd7268 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Typing :: Typed", ] -dependencies = ["httpx>=0.28.1,<1.0"] +dependencies = [] dynamic = ["version"] [project.urls] @@ -62,10 +62,8 @@ path = "openaq/__init__.py" dependencies = [ "coverage[toml]", "pytest", - "pytest-asyncio", "pytest-cov", "pytest-mock", - "respx", "freezegun", ] @@ -83,11 +81,7 @@ python = ["3.10", "3.11", "3.12", "3.13", "3.14"] [tool.hatch.envs.types] detached = true -dependencies = ["mypy", "lxml","httpx"] - -[[tool.mypy.overrides]] -module = ["httpx.*", "openaq"] -ignore_missing_imports = true +dependencies = ["mypy", "lxml"] [[tool.mypy.overrides]] module = ["orjson"] diff --git a/tests/integration/test_async_client.py b/tests/integration/test_async_client.py deleted file mode 100644 index 8406d248..00000000 --- a/tests/integration/test_async_client.py +++ /dev/null @@ -1,90 +0,0 @@ -import asyncio -import os - -import pytest - -from openaq._async.client import AsyncOpenAQ - - -@pytest.fixture(scope="session") -def event_loop(request): - """ - Redefine the event loop to support session/module-scoped fixtures; - see https://github.com/pytest-dev/pytest-asyncio/issues/68 - """ - policy = asyncio.get_event_loop_policy() - loop = policy.new_event_loop() - - try: - yield loop - finally: - loop.close() - - -@pytest.mark.asyncio(scope="class") -class TestAsyncClient: - loop: asyncio.AbstractEventLoop - - @pytest.fixture(autouse=True) - def setup(self): - self.client = AsyncOpenAQ(base_url=os.environ.get("TEST_BASE_URL")) - - async def test_locations_list(self): - await self.client.locations.list() - - async def test_locations_get(self): - await self.client.locations.get(1) - - async def test_latest_get(self): - await self.client.locations.latest(1) - - async def test_countries_list(self): - await self.client.countries.list() - - async def test_countries_get(self): - await self.client.countries.get(1) - - async def test_licenses_list(self): - await self.client.licenses.list() - - async def test_licenses_get(self): - await self.client.licenses.get(1) - - async def test_owners_list(self): - await self.client.owners.list() - - async def test_owners_get(self): - await self.client.owners.get(1) - - async def test_parameters_list(self): - await self.client.parameters.list() - - async def test_parameters_get(self): - await self.client.parameters.get(1) - - async def test_parameters_latest(self): - await self.client.parameters.latest(1) - - async def test_providers_list(self): - await self.client.providers.list() - - async def test_providers_get(self): - await self.client.providers.get(1) - - async def test_instruments_list(self): - await self.client.instruments.list() - - async def test_instruments_get(self): - await self.client.instruments.get(1) - - async def test_manufacturers_list(self): - await self.client.manufacturers.list() - - async def test_manufacturers_get(self): - await self.client.manufacturers.get(1) - - async def test_manufacturers_instruments(self): - await self.client.manufacturers.instruments(1) - - async def test_sensors_get(self): - await self.client.sensors.get(1) diff --git a/tests/integration/test_sync_client.py b/tests/integration/test_client.py similarity index 97% rename from tests/integration/test_sync_client.py rename to tests/integration/test_client.py index 4c4c3c94..097a7cac 100644 --- a/tests/integration/test_sync_client.py +++ b/tests/integration/test_client.py @@ -2,7 +2,7 @@ import pytest -from openaq._sync.client import OpenAQ +from openaq.client import OpenAQ class TestClient: diff --git a/tests/unit/async/__init__.py b/tests/unit/async/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/async/resources/test_async_countries.py b/tests/unit/async/resources/test_async_countries.py deleted file mode 100644 index 7953f2e6..00000000 --- a/tests/unit/async/resources/test_async_countries.py +++ /dev/null @@ -1,231 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.countries import Countries -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import CountriesResponse - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_single_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 1000, - "found": 142, - }, - "results": [ - { - "id": 1, - "code": "ID", - "name": "Indonesia", - "datetimeFirst": "2016-01-30T01:00:00Z", - "datetimeLast": "2025-11-26T02:00:00Z", - "parameters": [ - {"id": 1, "name": "pm10", "units": "µg/m³", "displayName": None}, - {"id": 2, "name": "pm25", "units": "µg/m³", "displayName": None}, - ], - } - ], - } - response.headers = {} - return response - - -@pytest.fixture -def mock_list_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 1000, - "found": 142, - }, - "results": [ - { - "id": 1, - "code": "ID", - "name": "Indonesia", - "datetimeFirst": "2016-01-30T01:00:00Z", - "datetimeLast": "2025-11-26T02:00:00Z", - "parameters": [ - {"id": 1, "name": "pm10", "units": "µg/m³", "displayName": None}, - {"id": 2, "name": "pm25", "units": "µg/m³", "displayName": None}, - ], - }, - { - "id": 2, - "code": "MY", - "name": "Malaysia", - "datetimeFirst": "2022-11-03T21:00:00Z", - "datetimeLast": "2025-11-26T02:00:00Z", - "parameters": [ - {"id": 1, "name": "pm10", "units": "µg/m³", "displayName": None}, - {"id": 2, "name": "pm25", "units": "µg/m³", "displayName": None}, - ], - }, - { - "id": 3, - "code": "CL", - "name": "Chile", - "datetimeFirst": "2016-01-30T01:00:00Z", - "datetimeLast": "2025-11-26T02:00:00Z", - "parameters": [ - {"id": 1, "name": "pm10", "units": "µg/m³", "displayName": None}, - {"id": 2, "name": "pm25", "units": "µg/m³", "displayName": None}, - ], - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def countries(mock_client): - return Countries(mock_client) - - -@pytest.mark.asyncio -class TestCountries: - async def test_get_calls_client_correctly( - self, countries, mock_client, mock_single_response - ): - mock_client._get.return_value = mock_single_response - result = await countries.get(1) - mock_client._get.assert_called_once_with("/countries/1") - assert isinstance(result, CountriesResponse) - assert len(result.results) == 1 - - async def test_list_with_defaults(self, countries, mock_client, mock_list_response): - mock_client._get.return_value = mock_list_response - result = await countries.list() - params = mock_client._get.call_args[1]["params"] - assert mock_client._get.call_args[0][0] == "/countries" - assert params["page"] == 1 - assert params["limit"] == 1000 - assert isinstance(result, CountriesResponse) - assert len(result.results) == 3 - - async def test_list_with_pagination( - self, countries, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - await countries.list(page=3, limit=50) - params = mock_client._get.call_args[1]["params"] - assert params["page"] == 3 - assert params["limit"] == 50 - - @pytest.mark.parametrize( - "sort_order,expected", - [ - ("asc", "asc"), - ("desc", "desc"), - ], - ) - async def test_list_with_sorting( - self, countries, mock_client, mock_list_response, sort_order, expected - ): - mock_client._get.return_value = mock_list_response - await countries.list(order_by="id", sort_order=sort_order) - params = mock_client._get.call_args[1]["params"] - assert params["order_by"] == "id" - assert params["sort_order"] == expected - - @pytest.mark.parametrize( - "parameters_id,providers_id", - [ - (1, 5), - ([1, 2, 3], [4, 5, 6]), - ], - ) - async def test_list_with_filters( - self, - countries, - mock_client, - mock_list_response, - parameters_id, - providers_id, - ): - mock_client._get.return_value = mock_list_response - await countries.list(parameters_id=parameters_id, providers_id=providers_id) - params = mock_client._get.call_args[1]["params"] - assert "parameters_id" in params - assert "providers_id" in params - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_countries_get_throws(self, countries, value): - with pytest.raises(IdentifierOutOfBoundsError): - await countries.get(value) - - @pytest.mark.parametrize( - "parameter,value", - [ - ('page', '1'), - ('limit', '1000'), - ('limit', 9999), - ('parameters_id', 2**31), - ('parameters_id', '999'), - ('parameters_id', [1, 2, 3, '4']), - ('parameters_id', [1, 2, 3, 2**31]), - ('parameters_id', True), - ('providers_id', 2**31), - ('providers_id', '999'), - ('providers_id', [1, 2, 3, '4']), - ('providers_id', [1, 2, 3, 2**31]), - ('providers_id', True), - ('sort_order', 'foo'), - ('sort_order', 1), - ('sort_order', False), - ('order_by', 1), - ('order_by', False), - ], - ids=[ - 'page value invalid type', - 'limit value invalid type', - 'limit value out of range', - 'parameters_id out of int range', - 'parameters_id invalid type, string', - 'parameters_id list contains invalid type, string', - 'parameters_id list contains int out of range', - 'parameters_id invalid type, boolean', - 'providers_id out of int range', - 'providers_id invalid type, string', - 'providers_id list contains invalid type, string', - 'providers_id list contains int out of range', - 'providers_id invalid type, boolean', - 'sort_order invalid value, unsupported string', - 'sort_order invalid value int', - 'sort_order invalid value bool', - 'order_by invalid value int', - 'order_by invalid value bool', - ], - ) - async def test_countries_list_throws(self, countries, parameter, value): - mock_params = {parameter: value} - with pytest.raises(InvalidParameterError): - await countries.list(**mock_params) diff --git a/tests/unit/async/resources/test_async_instruments.py b/tests/unit/async/resources/test_async_instruments.py deleted file mode 100644 index 6db6b17a..00000000 --- a/tests/unit/async/resources/test_async_instruments.py +++ /dev/null @@ -1,167 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.instruments import Instruments -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import InstrumentsResponse - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_single_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 1, - }, - "results": [ - { - "id": 12, - "name": "MA350", - "isMonitor": True, - "manufacturer": {"id": 5021, "name": "AethLabs"}, - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def mock_list_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 2, - }, - "results": [ - { - "id": 12, - "name": "MA350", - "isMonitor": True, - "manufacturer": {"id": 5021, "name": "AethLabs"}, - }, - { - "id": 13, - "name": "AIO 2", - "isMonitor": True, - "manufacturer": {"id": 5020, "name": "MetOne"}, - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def instruments(mock_client): - return Instruments(mock_client) - - -@pytest.mark.asyncio -class TestInstruments: - async def test_get_calls_client_correctly( - self, instruments, mock_client, mock_single_response - ): - mock_client._get.return_value = mock_single_response - result = await instruments.get(12) - mock_client._get.assert_called_once_with("/instruments/12") - assert isinstance(result, InstrumentsResponse) - assert len(result.results) == 1 - - async def test_list_with_defaults( - self, instruments, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - result = await instruments.list() - params = mock_client._get.call_args[1]["params"] - assert mock_client._get.call_args[0][0] == "/instruments" - assert params["page"] == 1 - assert params["limit"] == 1000 - assert isinstance(result, InstrumentsResponse) - assert len(result.results) == 2 - - async def test_list_with_pagination( - self, instruments, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - await instruments.list(page=3, limit=50) - params = mock_client._get.call_args[1]["params"] - assert params["page"] == 3 - assert params["limit"] == 50 - - @pytest.mark.parametrize( - "sort_order,expected", - [ - ("asc", "asc"), - ("desc", "desc"), - ], - ) - async def test_list_with_sorting( - self, instruments, mock_client, mock_list_response, sort_order, expected - ): - mock_client._get.return_value = mock_list_response - await instruments.list(order_by="id", sort_order=sort_order) - params = mock_client._get.call_args[1]["params"] - assert params["order_by"] == "id" - assert params["sort_order"] == expected - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_instruments_get_throws(self, instruments, value): - with pytest.raises(IdentifierOutOfBoundsError): - await instruments.get(value) - - @pytest.mark.parametrize( - "parameter,value", - [ - ('page', '1'), - ('limit', '1000'), - ('limit', 9999), - ('sort_order', 'foo'), - ('sort_order', 1), - ('sort_order', False), - ('order_by', 1), - ('order_by', False), - ], - ids=[ - 'page value invalid type', - 'limit value invalid type', - 'limit value out of range', - 'sort_order invalid value, unsupported string', - 'sort_order invalid value int', - 'sort_order invalid value bool', - 'order_by invalid value int', - 'order_by invalid value bool', - ], - ) - async def test_instruments_list_throws(self, instruments, parameter, value): - mock_params = {parameter: value} - with pytest.raises(InvalidParameterError): - await instruments.list(**mock_params) diff --git a/tests/unit/async/resources/test_async_licenses.py b/tests/unit/async/resources/test_async_licenses.py deleted file mode 100644 index 0a52a98a..00000000 --- a/tests/unit/async/resources/test_async_licenses.py +++ /dev/null @@ -1,177 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.licenses import Licenses -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import LicensesResponse - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_single_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 1, - }, - "results": [ - { - "id": 41, - "name": "CC BY 4.0", - "commercialUseAllowed": True, - "attributionRequired": True, - "shareAlikeRequired": False, - "modificationAllowed": True, - "redistributionAllowed": True, - "sourceUrl": "https://creativecommons.org/licenses/by/4.0/", - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def mock_list_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 2, - }, - "results": [ - { - "id": 38, - "name": "CC0 1.0", - "commercialUseAllowed": True, - "attributionRequired": False, - "shareAlikeRequired": False, - "modificationAllowed": True, - "redistributionAllowed": True, - "sourceUrl": "https://creativecommons.org/publicdomain/zero/1.0/deed.ca", - }, - { - "id": 41, - "name": "CC BY 4.0", - "commercialUseAllowed": True, - "attributionRequired": True, - "shareAlikeRequired": False, - "modificationAllowed": True, - "redistributionAllowed": True, - "sourceUrl": "https://creativecommons.org/licenses/by/4.0/", - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def licenses(mock_client): - return Licenses(mock_client) - - -@pytest.mark.asyncio -class TestLicenses: - async def test_get_calls_client_correctly( - self, licenses, mock_client, mock_single_response - ): - mock_client._get.return_value = mock_single_response - result = await licenses.get(41) - mock_client._get.assert_called_once_with("/licenses/41") - assert isinstance(result, LicensesResponse) - assert len(result.results) == 1 - - async def test_list_with_defaults(self, licenses, mock_client, mock_list_response): - mock_client._get.return_value = mock_list_response - result = await licenses.list() - params = mock_client._get.call_args[1]["params"] - assert mock_client._get.call_args[0][0] == "/licenses" - assert params["page"] == 1 - assert params["limit"] == 1000 - assert isinstance(result, LicensesResponse) - assert len(result.results) == 2 - - async def test_list_with_pagination( - self, licenses, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - await licenses.list(page=3, limit=50) - params = mock_client._get.call_args[1]["params"] - assert params["page"] == 3 - assert params["limit"] == 50 - - @pytest.mark.parametrize( - "sort_order,expected", - [ - ("asc", "asc"), - ("desc", "desc"), - ], - ) - async def test_list_with_sorting( - self, licenses, mock_client, mock_list_response, sort_order, expected - ): - mock_client._get.return_value = mock_list_response - await licenses.list(order_by="id", sort_order=sort_order) - params = mock_client._get.call_args[1]["params"] - assert params["order_by"] == "id" - assert params["sort_order"] == expected - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_get_throws(self, licenses, value): - with pytest.raises(IdentifierOutOfBoundsError): - await licenses.get(value) - - @pytest.mark.parametrize( - "parameter,value", - [ - ('page', '1'), - ('limit', '1000'), - ('limit', 9999), - ('sort_order', 'foo'), - ('sort_order', 1), - ('sort_order', False), - ('order_by', 1), - ('order_by', False), - ], - ids=[ - 'page value invalid type', - 'limit value invalid type', - 'limit value out of range', - 'sort_order invalid value, unsupported string', - 'sort_order invalid value int', - 'sort_order invalid value bool', - 'order_by invalid value int', - 'order_by invalid value bool', - ], - ) - async def test_list_throws(self, licenses, parameter, value): - mock_params = {parameter: value} - with pytest.raises(InvalidParameterError): - await licenses.list(**mock_params) diff --git a/tests/unit/async/resources/test_async_locations.py b/tests/unit/async/resources/test_async_locations.py deleted file mode 100644 index 4e5dd3e9..00000000 --- a/tests/unit/async/resources/test_async_locations.py +++ /dev/null @@ -1,571 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.locations import Locations -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import ( - LatestResponse, - LocationsResponse, - SensorsResponse, -) - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_sensors_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 8, - }, - "results": [ - { - "id": 3920, - "name": "pm25 µg/m³", - "parameter": { - "id": 2, - "name": "pm25", - "units": "µg/m³", - "displayName": "PM2.5", - }, - "datetimeFirst": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - "datetimeLast": { - "utc": "2025-11-26T17:00:00Z", - "local": "2025-11-26T10:00:00-07:00", - }, - "coverage": { - "expectedCount": 1, - "expectedInterval": "01:00:00", - "observedCount": 66563, - "observedInterval": "66563:00:00", - "percentComplete": 6656300, - "percentCoverage": 6656300, - "datetimeFrom": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - "datetimeTo": { - "utc": "2025-11-26T17:00:00Z", - "local": "2025-11-26T10:00:00-07:00", - }, - }, - "latest": { - "datetime": { - "utc": "2025-11-26T17:00:00Z", - "local": "2025-11-26T10:00:00-07:00", - }, - "value": 3.6, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - }, - "summary": { - "min": -4.9, - "q02": None, - "q25": None, - "median": None, - "q75": None, - "q98": None, - "max": 169.9, - "avg": 5.779898468115439, - "sd": None, - }, - } - ], - } - response.headers = {} - return response - - -@pytest.fixture -def mock_latest_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 8, - }, - "results": [ - { - "datetime": { - "utc": "2025-11-26T17:00:00Z", - "local": "2025-11-26T10:00:00-07:00", - }, - "value": 0.013, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "sensorsId": 3916, - "locationsId": 2178, - }, - { - "datetime": { - "utc": "2025-11-26T17:00:00Z", - "local": "2025-11-26T10:00:00-07:00", - }, - "value": 0.0003, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "sensorsId": 3918, - "locationsId": 2178, - }, - { - "datetime": { - "utc": "2025-11-26T17:00:00Z", - "local": "2025-11-26T10:00:00-07:00", - }, - "value": 3.6, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "sensorsId": 3920, - "locationsId": 2178, - }, - { - "datetime": { - "utc": "2025-11-26T17:00:00Z", - "local": "2025-11-26T10:00:00-07:00", - }, - "value": 0.023, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "sensorsId": 3917, - "locationsId": 2178, - }, - { - "datetime": { - "utc": "2025-11-26T17:00:00Z", - "local": "2025-11-26T10:00:00-07:00", - }, - "value": 10, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "sensorsId": 3919, - "locationsId": 2178, - }, - { - "datetime": { - "utc": "2025-11-26T17:00:00Z", - "local": "2025-11-26T10:00:00-07:00", - }, - "value": 0.1, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "sensorsId": 25227, - "locationsId": 2178, - }, - { - "datetime": { - "utc": "2025-09-19T16:00:00Z", - "local": "2025-09-19T10:00:00-06:00", - }, - "value": 0.01, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "sensorsId": 4272103, - "locationsId": 2178, - }, - { - "datetime": { - "utc": "2025-09-19T16:00:00Z", - "local": "2025-09-19T10:00:00-06:00", - }, - "value": 0.002, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "sensorsId": 4272226, - "locationsId": 2178, - }, - ], - } - response.headers = {} - return response - - -@pytest.fixture -def mock_single_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 1, - }, - "results": [ - { - "id": 2178, - "name": "Del Norte", - "locality": "Albuquerque", - "timezone": "America/Denver", - "country": {"id": 155, "code": "US", "name": "United States"}, - "owner": {"id": 4, "name": "Unknown Governmental Organization"}, - "provider": {"id": 119, "name": "AirNow"}, - "isMobile": False, - "isMonitor": True, - "instruments": [{"id": 2, "name": "Government Monitor"}], - "sensors": [ - { - "id": 25227, - "name": "co ppm", - "parameter": { - "id": 8, - "name": "co", - "units": "ppm", - "displayName": "CO", - }, - } - ], - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "licenses": [ - { - "id": 33, - "name": "US Public Domain", - "attribution": { - "name": "Unknown Governmental Organization", - "url": None, - }, - "dateFrom": "2016-01-30", - "dateTo": None, - } - ], - "bounds": [-106.584702, 35.1353, -106.584702, 35.1353], - "distance": None, - "datetimeFirst": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - "datetimeLast": { - "utc": "2025-11-26T13:00:00Z", - "local": "2025-11-26T06:00:00-07:00", - }, - } - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def mock_list_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 1000, - "found": 1, - }, - "results": [ - { - "id": 2178, - "name": "Del Norte", - "locality": "Albuquerque", - "timezone": "America/Denver", - "country": {"id": 155, "code": "US", "name": "United States"}, - "owner": {"id": 4, "name": "Unknown Governmental Organization"}, - "provider": {"id": 119, "name": "AirNow"}, - "isMobile": False, - "isMonitor": True, - "instruments": [{"id": 2, "name": "Government Monitor"}], - "sensors": [ - { - "id": 25227, - "name": "co ppm", - "parameter": { - "id": 8, - "name": "co", - "units": "ppm", - "displayName": "CO", - }, - } - ], - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - "licenses": [ - { - "id": 33, - "name": "US Public Domain", - "attribution": { - "name": "Unknown Governmental Organization", - "url": None, - }, - "dateFrom": "2016-01-30", - "dateTo": None, - } - ], - "bounds": [-106.584702, 35.1353, -106.584702, 35.1353], - "distance": None, - "datetimeFirst": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - "datetimeLast": { - "utc": "2025-11-26T13:00:00Z", - "local": "2025-11-26T06:00:00-07:00", - }, - } - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def locations(mock_client): - return Locations(mock_client) - - -@pytest.mark.asyncio -class TestLocations: - async def test_get_calls_client_correctly( - self, locations, mock_client, mock_single_response - ): - mock_client._get.return_value = mock_single_response - result = await locations.get(2178) - mock_client._get.assert_called_once_with("/locations/2178") - assert isinstance(result, LocationsResponse) - assert len(result.results) == 1 - - async def test_latest_calls_client_correctly( - self, locations, mock_client, mock_latest_response - ): - mock_client._get.return_value = mock_latest_response - result = await locations.latest(2178) - mock_client._get.assert_called_once_with("/locations/2178/latest") - assert isinstance(result, LatestResponse) - assert len(result.results) == 8 - - async def test_sensors_calls_client_correctly( - self, locations, mock_client, mock_sensors_response - ): - mock_client._get.return_value = mock_sensors_response - result = await locations.sensors(2178) - mock_client._get.assert_called_once_with("/locations/2178/sensors") - assert isinstance(result, SensorsResponse) - assert len(result.results) == 1 - - async def test_list_with_defaults(self, locations, mock_client, mock_list_response): - mock_client._get.return_value = mock_list_response - result = await locations.list() - params = mock_client._get.call_args[1]["params"] - assert mock_client._get.call_args[0][0] == "/locations" - assert params["page"] == 1 - assert params["limit"] == 100 - assert isinstance(result, LocationsResponse) - assert len(result.results) == 1 - - async def test_list_with_pagination( - self, locations, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - await locations.list(page=3, limit=50) - params = mock_client._get.call_args[1]["params"] - assert params["page"] == 3 - assert params["limit"] == 50 - - @pytest.mark.parametrize( - "sort_order,expected", - [ - ("asc", "asc"), - ("desc", "desc"), - ], - ) - async def test_list_with_sorting( - self, locations, mock_client, mock_list_response, sort_order, expected - ): - mock_client._get.return_value = mock_list_response - await locations.list(order_by="id", sort_order=sort_order) - params = mock_client._get.call_args[1]["params"] - assert params["order_by"] == "id" - assert params["sort_order"] == expected - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_location_sensors_throws(self, locations, value): - with pytest.raises(IdentifierOutOfBoundsError): - await locations.sensors(value) - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_location_latest_throws(self, locations, value): - with pytest.raises(IdentifierOutOfBoundsError): - await locations.latest(value) - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_locations_get_throws(self, locations, value): - with pytest.raises(IdentifierOutOfBoundsError): - await locations.get(value) - - @pytest.mark.parametrize( - "parameter,value", - [ - ('page', '1'), - ('limit', '1000'), - ('limit', 9999), - ('providers_id', 2**31), - ('providers_id', '999'), - ('providers_id', [1, 2, 3, '4']), - ('providers_id', [1, 2, 3, 2**31]), - ('providers_id', True), - ('countries_id', 2**31), - ('countries_id', '999'), - ('countries_id', [1, 2, 3, '4']), - ('countries_id', [1, 2, 3, 2**31]), - ('countries_id', True), - ('parameters_id', 2**31), - ('parameters_id', '999'), - ('parameters_id', [1, 2, 3, '4']), - ('parameters_id', [1, 2, 3, 2**31]), - ('parameters_id', True), - ('licenses_id', 2**31), - ('licenses_id', '999'), - ('licenses_id', [1, 2, 3, '4']), - ('licenses_id', [1, 2, 3, 2**31]), - ('licenses_id', True), - ('instruments_id', 2**31), - ('instruments_id', '999'), - ('instruments_id', [1, 2, 3, '4']), - ('instruments_id', [1, 2, 3, 2**31]), - ('instruments_id', True), - ('manufacturers_id', 2**31), - ('manufacturers_id', '999'), - ('manufacturers_id', [1, 2, 3, '4']), - ('manufacturers_id', [1, 2, 3, 2**31]), - ('manufacturers_id', True), - ('owners_id', 2**31), - ('owners_id', '999'), - ('owners_id', [1, 2, 3, '4']), - ('owners_id', [1, 2, 3, 2**31]), - ('owners_id', True), - ('iso', 42), - ('iso', True), - ('iso', 'USA'), - ('mobile', 'True'), - ('mobile', 1), - ('monitor', 'True'), - ('monitor', 1), - ('sort_order', 'foo'), - ('sort_order', 1), - ('sort_order', False), - ('order_by', 1), - ('order_by', False), - ], - ids=[ - 'page value invalid type', - 'limit value invalid type', - 'limit value out of range', - 'providers_id out of int range', - 'providers_id invalid type, string', - 'providers_id list contains invalid type, string', - 'providers_id list contains int out of range', - 'providers_id invalid type, boolean', - 'countries_id out of int range', - 'countries_id invalid type, string', - 'countries_id list contains invalid type, string', - 'countries_id list contains int out of range', - 'countries_id invalid type, boolean', - 'parameters_id out of int range', - 'parameters_id invalid type, string', - 'parameters_id list contains invalid type, string', - 'parameters_id list contains int out of range', - 'parameters_id invalid type, boolean', - 'licenses_id out of int range', - 'licenses_id invalid type, string', - 'licenses_id list contains invalid type, string', - 'licenses_id list contains int out of range', - 'licenses_id invalid type, boolean', - 'instruments_id out of int range', - 'instruments_id invalid type, string', - 'instruments_id list contains invalid type, string', - 'instruments_id list contains int out of range', - 'instruments_id invalid type, boolean', - 'manufacturers_id out of int range', - 'manufacturers_id invalid type, string', - 'manufacturers_id list contains invalid type, string', - 'manufacturers_id list contains int out of range', - 'manufacturers_id invalid type, boolean', - 'owners_id out of int range', - 'owners_id invalid type, string', - 'owners_id list contains invalid type, string', - 'owners_id list contains int out of range', - 'owners_id invalid type, boolean', - 'iso invalid type integer', - 'iso invalid type boolean', - 'iso string too many characters', - 'mobile invalid value string', - 'mobile invalid value int', - 'monitor invalue value string', - 'monitor invalid value int', - 'sort_order invalid value, unsupported string', - 'sort_order invalid value int', - 'sort_order invalid value bool', - 'order_by invalid value int', - 'order_by invalid value bool', - ], - ) - async def test_locations_list_throws(self, locations, parameter, value): - mock_params = {parameter: value} - with pytest.raises(InvalidParameterError): - await locations.list(**mock_params) - - @pytest.mark.parametrize( - "mock_params,expected_error", - [ - ( - {'iso': 'US', 'countries_id': 42}, - 'iso cannot be used with countries_id', - ), - ( - { - 'bbox': (-43.549381, -23.157246, -42.560611, -22.719029), - 'coordinates': (42, 42), - 'radius': 42, - }, - 'bbox cannot be used with coordinates/radius parameters', - ), - ], - ids=[ - 'countries query params', - 'bbox with coordinates/radius query params', - ], - ) - async def test_locations_list_mutually_exclusive_params_throws( - self, locations, mock_params, expected_error - ): - with pytest.raises(InvalidParameterError, match=expected_error): - await locations.list(**mock_params) diff --git a/tests/unit/async/resources/test_async_manufacturers.py b/tests/unit/async/resources/test_async_manufacturers.py deleted file mode 100644 index ff599bb7..00000000 --- a/tests/unit/async/resources/test_async_manufacturers.py +++ /dev/null @@ -1,232 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.manufacturers import Manufacturers -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import InstrumentsResponse, ManufacturersResponse - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_single_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 1, - }, - "results": [ - { - "id": 5020, - "name": "MetOne", - "instruments": [ - {"id": 13, "name": "AIO 2"}, - {"id": 14, "name": "BAM 1020"}, - {"id": 18, "name": "BAM 1022"}, - ], - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def mock_instruments_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 3, - }, - "results": [ - { - "id": 13, - "name": "AIO 2", - "isMonitor": True, - "manufacturer": {"id": 5020, "name": "MetOne"}, - }, - { - "id": 14, - "name": "BAM 1020", - "isMonitor": True, - "manufacturer": {"id": 5020, "name": "MetOne"}, - }, - { - "id": 18, - "name": "BAM 1022", - "isMonitor": True, - "manufacturer": {"id": 5020, "name": "MetOne"}, - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def mock_list_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 2, - }, - "results": [ - { - "id": 1, - "name": "OpenAQ admin", - "instruments": [{"id": 1, "name": "N/A"}], - }, - { - "id": 5020, - "name": "MetOne", - "instruments": [ - {"id": 13, "name": "AIO 2"}, - {"id": 14, "name": "BAM 1020"}, - {"id": 18, "name": "BAM 1022"}, - ], - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def manufacturers(mock_client): - return Manufacturers(mock_client) - - -@pytest.mark.asyncio -class TestManufacturers: - async def test_get_calls_client_correctly( - self, manufacturers, mock_client, mock_single_response - ): - mock_client._get.return_value = mock_single_response - result = await manufacturers.get(5020) - mock_client._get.assert_called_once_with("/manufacturers/5020") - assert isinstance(result, ManufacturersResponse) - assert len(result.results) == 1 - - async def test_instruments_calls_client_correctly( - self, manufacturers, mock_client, mock_instruments_response - ): - mock_client._get.return_value = mock_instruments_response - result = await manufacturers.instruments(5020) - mock_client._get.assert_called_once_with("/manufacturers/5020/instruments") - assert isinstance(result, InstrumentsResponse) - assert len(result.results) == 3 - - async def test_list_with_defaults( - self, manufacturers, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - result = await manufacturers.list() - params = mock_client._get.call_args[1]["params"] - assert mock_client._get.call_args[0][0] == "/manufacturers" - assert params["page"] == 1 - assert params["limit"] == 1000 - assert isinstance(result, ManufacturersResponse) - assert len(result.results) == 2 - - async def test_list_with_pagination( - self, manufacturers, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - await manufacturers.list(page=3, limit=50) - params = mock_client._get.call_args[1]["params"] - assert params["page"] == 3 - assert params["limit"] == 50 - - @pytest.mark.parametrize( - "sort_order,expected", - [ - ("asc", "asc"), - ("desc", "desc"), - ], - ) - async def test_list_with_sorting( - self, manufacturers, mock_client, mock_list_response, sort_order, expected - ): - mock_client._get.return_value = mock_list_response - await manufacturers.list(order_by="id", sort_order=sort_order) - params = mock_client._get.call_args[1]["params"] - assert params["order_by"] == "id" - assert params["sort_order"] == expected - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_get_throws(self, manufacturers, value): - with pytest.raises(IdentifierOutOfBoundsError): - await manufacturers.get(value) - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_instruments_throws(self, manufacturers, value): - with pytest.raises(IdentifierOutOfBoundsError): - await manufacturers.instruments(value) - - @pytest.mark.parametrize( - "parameter,value", - [ - ('page', '1'), - ('limit', '1000'), - ('limit', 9999), - ('sort_order', 'foo'), - ('sort_order', 1), - ('sort_order', False), - ('order_by', 1), - ('order_by', False), - ], - ids=[ - 'page value invalid type', - 'limit value invalid type', - 'limit value out of range', - 'sort_order invalid value, unsupported string', - 'sort_order invalid value int', - 'sort_order invalid value bool', - 'order_by invalid value int', - 'order_by invalid value bool', - ], - ) - async def test_list_throws(self, manufacturers, parameter, value): - mock_params = {parameter: value} - with pytest.raises(InvalidParameterError): - await manufacturers.list(**mock_params) diff --git a/tests/unit/async/resources/test_async_measurements.py b/tests/unit/async/resources/test_async_measurements.py deleted file mode 100644 index ebdca089..00000000 --- a/tests/unit/async/resources/test_async_measurements.py +++ /dev/null @@ -1,379 +0,0 @@ -import datetime -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.measurements import Measurements -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import MeasurementsResponse - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_measurements_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": ">100", - }, - "results": [ - { - "value": -0.1, - "flagInfo": {"hasFlags": False}, - "parameter": { - "id": 2, - "name": "pm25", - "units": "µg/m³", - "displayName": None, - }, - "period": { - "label": "raw", - "interval": "01:00:00", - "datetimeFrom": { - "utc": "2016-03-06T19:00:00Z", - "local": "2016-03-06T12:00:00-07:00", - }, - "datetimeTo": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - }, - "coordinates": None, - "summary": None, - "coverage": { - "expectedCount": 1, - "expectedInterval": "01:00:00", - "observedCount": 1, - "observedInterval": "01:00:00", - "percentComplete": 100, - "percentCoverage": 100, - "datetimeFrom": { - "utc": "2016-03-06T19:00:00Z", - "local": "2016-03-06T12:00:00-07:00", - }, - "datetimeTo": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - }, - }, - { - "value": 1.1, - "flagInfo": {"hasFlags": False}, - "parameter": { - "id": 2, - "name": "pm25", - "units": "µg/m³", - "displayName": None, - }, - "period": { - "label": "raw", - "interval": "01:00:00", - "datetimeFrom": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - "datetimeTo": { - "utc": "2016-03-06T21:00:00Z", - "local": "2016-03-06T14:00:00-07:00", - }, - }, - "coordinates": None, - "summary": None, - "coverage": { - "expectedCount": 1, - "expectedInterval": "01:00:00", - "observedCount": 1, - "observedInterval": "01:00:00", - "percentComplete": 100, - "percentCoverage": 100, - "datetimeFrom": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - "datetimeTo": { - "utc": "2016-03-06T21:00:00Z", - "local": "2016-03-06T14:00:00-07:00", - }, - }, - }, - ], - } - response.headers = {} - return response - - -@pytest.fixture -def measurements(mock_client): - return Measurements(mock_client) - - -@pytest.mark.asyncio -class TestMeasurements: - async def test_list_with_defaults( - self, measurements, mock_client, mock_measurements_response - ): - mock_client._get.return_value = mock_measurements_response - result = await measurements.list(sensors_id=123, data='measurements') - - params = mock_client._get.call_args[1]["params"] - path = mock_client._get.call_args[0][0] - - assert path == "/sensors/123/measurements" - assert params["page"] == 1 - assert params["limit"] == 1000 - assert "datetime_from" not in params - assert "datetime_to" not in params - assert "date_from" not in params - assert "date_to" not in params - assert isinstance(result, MeasurementsResponse) - assert len(result.results) == 2 - - async def test_list_with_pagination( - self, measurements, mock_client, mock_measurements_response - ): - mock_client._get.return_value = mock_measurements_response - await measurements.list(sensors_id=123, data='measurements', page=3, limit=50) - - params = mock_client._get.call_args[1]["params"] - assert params["page"] == 3 - assert params["limit"] == 50 - - async def test_list_with_datetime_from_as_string( - self, measurements, mock_client, mock_measurements_response - ): - mock_client._get.return_value = mock_measurements_response - await measurements.list( - sensors_id=123, data='measurements', datetime_from="2024-01-01" - ) - - params = mock_client._get.call_args[1]["params"] - assert params["datetime_from"] == "2024-01-01T00:00:00" - - async def test_list_with_datetime_from_as_datetime( - self, measurements, mock_client, mock_measurements_response - ): - mock_client._get.return_value = mock_measurements_response - dt = datetime.datetime(2024, 1, 1, 12, 30, 0) - await measurements.list(sensors_id=123, data='measurements', datetime_from=dt) - - params = mock_client._get.call_args[1]["params"] - assert "2024-01-01" in params["datetime_from"] - - async def test_list_with_datetime_to_as_string( - self, measurements, mock_client, mock_measurements_response - ): - mock_client._get.return_value = mock_measurements_response - await measurements.list( - sensors_id=123, - data='measurements', - datetime_from="2024-01-01", - datetime_to="2024-01-31", - ) - - params = mock_client._get.call_args[1]["params"] - assert params["datetime_from"] == "2024-01-01T00:00:00" - assert params["datetime_to"] == "2024-01-31T00:00:00" - - async def test_list_with_datetime_to_as_datetime( - self, measurements, mock_client, mock_measurements_response - ): - mock_client._get.return_value = mock_measurements_response - dt_from = datetime.datetime(2024, 1, 1) - dt_to = datetime.datetime(2024, 1, 31) - await measurements.list( - sensors_id=123, - data='measurements', - datetime_from=dt_from, - datetime_to=dt_to, - ) - - params = mock_client._get.call_args[1]["params"] - assert "2024-01-01" in params["datetime_from"] - assert "2024-01-31" in params["datetime_to"] - - @pytest.mark.parametrize( - "data, rollup", - [ - ("measurements", "hourly"), - ("measurements", "daily"), - ("hours", "daily"), - ("hours", "yearly"), - ("days", "yearly"), - ], - ids=[ - "measurements hourly", - "measurements daily", - "hours daily", - "hours yearly", - "days yearly", - ], - ) - async def test_list_with_data_rollup_parameter( - self, measurements, mock_client, mock_measurements_response, data, rollup - ): - mock_client._get.return_value = mock_measurements_response - await measurements.list(sensors_id=123, data=data, rollup=rollup) - - path = mock_client._get.call_args[0][0] - assert path == f"/sensors/123/{data}/{rollup}" - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_list_invalid_sensors_id(self, measurements, value): - with pytest.raises(IdentifierOutOfBoundsError): - await measurements.list(sensors_id=value, data="measurements") - - @pytest.mark.parametrize( - "parameter,value", - [ - ('page', '1'), - ('page', 0), - ('page', -1), - ('limit', '1000'), - ('limit', 0), - ('limit', 1001), - ('limit', -1), - ], - ids=[ - 'page value invalid type', - 'page value zero', - 'page value negative', - 'limit value invalid type', - 'limit value zero', - 'limit value out of range high', - 'limit value negative', - ], - ) - async def test_list_invalid_pagination_params(self, measurements, parameter, value): - mock_params = {'sensors_id': 123, 'data': 'measurements', parameter: value} - with pytest.raises(InvalidParameterError): - await measurements.list(**mock_params) - - @pytest.mark.parametrize( - "data_value", - ['invalid_data', 123, True, False], - ids=[ - 'data invalid string', - 'data invalid type int', - 'data invalid type bool True', - 'data invalid type bool False', - ], - ) - async def test_list_invalid_data_param(self, measurements, data_value): - with pytest.raises(InvalidParameterError): - await measurements.list(sensors_id=123, data=data_value) - - @pytest.mark.parametrize( - "rollup_value", - ['invalid_rollup', 123, True, False], - ids=[ - 'rollup invalid string', - 'rollup invalid type int', - 'rollup invalid type bool True', - 'rollup invalid type bool False', - ], - ) - async def test_list_invalid_rollup_param(self, measurements, rollup_value): - with pytest.raises(InvalidParameterError): - await measurements.list( - sensors_id=123, data='measurements', rollup=rollup_value - ) - - @pytest.mark.parametrize( - "datetime_from,datetime_to", - [ - ('invalid-date', None), - (None, 'invalid-date'), - ('2024-13-01', None), - ('2024-01-32', None), - (123, None), - (None, 123), - (True, None), - (None, False), - ], - ids=[ - 'datetime_from invalid format', - 'datetime_to invalid format', - 'datetime_from invalid month', - 'datetime_from invalid day', - 'datetime_from invalid type int', - 'datetime_to invalid type int', - 'datetime_from invalid type bool', - 'datetime_to invalid type bool', - ], - ) - async def test_list_invalid_datetime_params( - self, measurements, mock_client, datetime_from, datetime_to - ): - with pytest.raises(InvalidParameterError): - await measurements.list( - sensors_id=123, - data='measurements', - datetime_from=datetime_from, - datetime_to=datetime_to, - ) - mock_client._get.assert_not_called() - - async def test_list_date_overload_uses_date_params( - self, measurements, mock_client, mock_measurements_response - ): - mock_client._get.return_value = mock_measurements_response - - await measurements.list( - sensors_id=123, - data='days', - date_from="2026-01-01", - date_to="2026-02-12", - ) - - params = mock_client._get.call_args[1]["params"] - assert "date_from" in params - assert "date_to" in params - assert "datetime_from" not in params - assert "datetime_to" not in params - - @pytest.mark.parametrize( - "data", - [ - pytest.param("days", id="days-DateData"), - pytest.param("years", id="years-DateData"), - ], - ) - async def test_list_date_overload_accepts_date_params( - self, measurements, mock_client, mock_measurements_response, data - ): - mock_client._get.return_value = mock_measurements_response - - date_from = datetime.date(2026, 1, 1) - date_to = datetime.date(2026, 2, 12) - - await measurements.list( - sensors_id=123, - data=data, - date_from=date_from, - date_to=date_to, - ) - - params = mock_client._get.call_args[1]["params"] - assert "date_from" in params - assert "date_to" in params diff --git a/tests/unit/async/resources/test_async_owners.py b/tests/unit/async/resources/test_async_owners.py deleted file mode 100644 index a7b8eb84..00000000 --- a/tests/unit/async/resources/test_async_owners.py +++ /dev/null @@ -1,147 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.owners import Owners -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import OwnersResponse - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_single_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": ">100", - }, - "results": [{"id": 1, "name": "OpenAQ admin"}], - } - response.headers = {} - - return response - - -@pytest.fixture -def mock_list_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 1000, - "found": 3, - }, - "results": [ - {"id": 1, "name": "OpenAQ admin"}, - {"id": 4, "name": "Unknown Governmental Organization"}, - {"id": 5, "name": "Unknown Research Organization"}, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def owners(mock_client): - return Owners(mock_client) - - -@pytest.mark.asyncio -class TestOwners: - async def test_get_calls_client_correctly( - self, owners, mock_client, mock_single_response - ): - mock_client._get.return_value = mock_single_response - result = await owners.get(1) - mock_client._get.assert_called_once_with("/owners/1") - assert isinstance(result, OwnersResponse) - assert len(result.results) == 1 - - async def test_list_with_defaults(self, owners, mock_client, mock_list_response): - mock_client._get.return_value = mock_list_response - result = await owners.list() - params = mock_client._get.call_args[1]["params"] - assert mock_client._get.call_args[0][0] == "/owners" - assert params["page"] == 1 - assert params["limit"] == 1000 - assert isinstance(result, OwnersResponse) - assert len(result.results) == 3 - - async def test_list_with_pagination(self, owners, mock_client, mock_list_response): - mock_client._get.return_value = mock_list_response - await owners.list(page=3, limit=50) - params = mock_client._get.call_args[1]["params"] - assert params["page"] == 3 - assert params["limit"] == 50 - - @pytest.mark.parametrize( - "sort_order,expected", - [ - ("asc", "asc"), - ("desc", "desc"), - ], - ) - async def test_list_with_sorting( - self, owners, mock_client, mock_list_response, sort_order, expected - ): - mock_client._get.return_value = mock_list_response - await owners.list(order_by="id", sort_order=sort_order) - params = mock_client._get.call_args[1]["params"] - assert params["order_by"] == "id" - assert params["sort_order"] == expected - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_owners_get_throws(self, owners, value): - with pytest.raises(IdentifierOutOfBoundsError): - await owners.get(value) - - @pytest.mark.parametrize( - "parameter,value", - [ - ('page', '1'), - ('limit', '1000'), - ('limit', 9999), - ('sort_order', 'foo'), - ('sort_order', 1), - ('sort_order', False), - ('order_by', 1), - ('order_by', False), - ], - ids=[ - 'page value invalid type', - 'limit value invalid type', - 'limit value out of range', - 'sort_order invalid value, unsupported string', - 'sort_order invalid value int', - 'sort_order invalid value bool', - 'order_by invalid value int', - 'order_by invalid value bool', - ], - ) - async def test_owners_list_throws(self, owners, parameter, value): - mock_params = {parameter: value} - with pytest.raises(InvalidParameterError): - await owners.list(**mock_params) diff --git a/tests/unit/async/resources/test_async_parameters.py b/tests/unit/async/resources/test_async_parameters.py deleted file mode 100644 index b253728a..00000000 --- a/tests/unit/async/resources/test_async_parameters.py +++ /dev/null @@ -1,255 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.parameters import Parameters -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import LatestResponse, ParametersResponse - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_single_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 1, - }, - "results": [ - { - "id": 2, - "name": "pm25", - "units": "µg/m³", - "displayName": "PM2.5", - "description": "Particulate matter less than 2.5 micrometers in diameter mass concentration", - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def mock_list_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 2, - }, - "results": [ - { - "id": 1, - "name": "pm10", - "units": "µg/m³", - "displayName": "PM10", - "description": "Particulate matter less than 10 micrometers in diameter mass concentration", - }, - { - "id": 2, - "name": "pm25", - "units": "µg/m³", - "displayName": "PM2.5", - "description": "Particulate matter less than 2.5 micrometers in diameter mass concentration", - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def mock_latest_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 2, - }, - "results": [ - { - "datetime": { - "utc": "2025-11-26T15:00:00Z", - "local": "2025-11-27T00:00:00+09:00", - }, - "value": 26, - "coordinates": {"latitude": 35.21815, "longitude": 128.57425}, - "sensorsId": 8539597, - "locationsId": 2622686, - }, - { - "datetime": { - "utc": "2025-11-26T14:00:00Z", - "local": "2025-11-26T16:00:00+02:00", - }, - "value": -1, - "coordinates": { - "latitude": 54.88361359025449, - "longitude": 23.83583450024486, - }, - "sensorsId": 23735, - "locationsId": 8152, - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def parameters(mock_client): - return Parameters(mock_client) - - -@pytest.mark.asyncio -class TestParameters: - async def test_get_calls_client_correctly( - self, parameters, mock_client, mock_single_response - ): - mock_client._get.return_value = mock_single_response - result = await parameters.get(2) - mock_client._get.assert_called_once_with("/parameters/2") - assert isinstance(result, ParametersResponse) - assert len(result.results) == 1 - - async def test_latest_calls_client_correctly( - self, parameters, mock_client, mock_latest_response - ): - mock_client._get.return_value = mock_latest_response - result = await parameters.latest(2) - mock_client._get.assert_called_once_with("/parameters/2/latest") - assert isinstance(result, LatestResponse) - assert len(result.results) == 2 - - async def test_list_with_defaults( - self, parameters, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - result = await parameters.list() - params = mock_client._get.call_args[1]["params"] - assert mock_client._get.call_args[0][0] == "/parameters" - assert params["page"] == 1 - assert params["limit"] == 1000 - assert isinstance(result, ParametersResponse) - assert len(result.results) == 2 - - async def test_list_with_pagination( - self, parameters, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - await parameters.list(page=3, limit=50) - params = mock_client._get.call_args[1]["params"] - assert params["page"] == 3 - assert params["limit"] == 50 - - @pytest.mark.parametrize( - "sort_order,expected", - [ - ("asc", "asc"), - ("desc", "desc"), - ], - ) - async def test_list_with_sorting( - self, parameters, mock_client, mock_list_response, sort_order, expected - ): - mock_client._get.return_value = mock_list_response - await parameters.list(order_by="id", sort_order=sort_order) - params = mock_client._get.call_args[1]["params"] - assert params["order_by"] == "id" - assert params["sort_order"] == expected - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_parameters_get_throws(self, parameters, value): - with pytest.raises(IdentifierOutOfBoundsError): - await parameters.get(value) - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_parameters_latest_throws(self, parameters, value): - with pytest.raises(IdentifierOutOfBoundsError): - await parameters.latest(value) - - @pytest.mark.parametrize( - "parameter,value", - [ - ('page', '1'), - ('limit', '1000'), - ('limit', 9999), - ('parameter_type', 'invalid'), - ('parameter_type', 1), - ('iso', 42), - ('iso', True), - ('iso', 'USA'), - ('countries_id', 2**31), - ('countries_id', '999'), - ('countries_id', [1, 2, 3, '4']), - ('countries_id', [1, 2, 3, 2**31]), - ('countries_id', True), - ('sort_order', 'foo'), - ('sort_order', 1), - ('sort_order', False), - ('order_by', 1), - ('order_by', False), - ], - ids=[ - 'page value invalid type', - 'limit value invalid type', - 'limit value out of range', - 'parameter_type invalid, not supported string', - 'parameter_type invalid type int', - 'iso invalid type integer', - 'iso invalid type boolean', - 'iso string too many characters', - 'countries_id out of int range', - 'countries_id invalid type, string', - 'countries_id list contains invalid type, string', - 'countries_id list contains int out of range', - 'countries_id invalid type, boolean', - 'sort_order invalid value, unsupported string', - 'sort_order invalid value int', - 'sort_order invalid value bool', - 'order_by invalid value int', - 'order_by invalid value bool', - ], - ) - async def test_parameters_list_throws(self, parameters, parameter, value): - mock_params = {parameter: value} - with pytest.raises(InvalidParameterError): - await parameters.list(**mock_params) diff --git a/tests/unit/async/resources/test_async_providers.py b/tests/unit/async/resources/test_async_providers.py deleted file mode 100644 index 8fb3c2b3..00000000 --- a/tests/unit/async/resources/test_async_providers.py +++ /dev/null @@ -1,225 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.providers import Providers -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import ProvidersResponse - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_single_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 1, - }, - "results": [ - { - "id": 119, - "name": "AirNow", - "sourceName": "AirNow", - "exportPrefix": "airnow", - "datetimeAdded": "2023-03-29T20:23:57.054584Z", - "datetimeFirst": "2016-01-30T02:00:00Z", - "datetimeLast": "2025-11-26T15:15:00Z", - "entitiesId": 1, - "parameters": [ - {"id": 1, "name": "pm10", "units": "µg/m³", "displayName": None}, - {"id": 2, "name": "pm25", "units": "µg/m³", "displayName": None}, - ], - "bbox": { - "type": "Polygon", - "coordinates": [ - [ - [-161.767, -34.5766], - [-161.767, 70.1319], - [123.424434, 70.1319], - [123.424434, -34.5766], - [-161.767, -34.5766], - ] - ], - }, - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def mock_list_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 1, - }, - "results": [ - { - "id": 119, - "name": "AirNow", - "sourceName": "AirNow", - "exportPrefix": "airnow", - "datetimeAdded": "2023-03-29T20:23:57.054584Z", - "datetimeFirst": "2016-01-30T02:00:00Z", - "datetimeLast": "2025-11-26T15:15:00Z", - "entitiesId": 1, - "parameters": [ - {"id": 1, "name": "pm10", "units": "µg/m³", "displayName": None}, - {"id": 2, "name": "pm25", "units": "µg/m³", "displayName": None}, - ], - "bbox": { - "type": "Polygon", - "coordinates": [ - [ - [-161.767, -34.5766], - [-161.767, 70.1319], - [123.424434, 70.1319], - [123.424434, -34.5766], - [-161.767, -34.5766], - ] - ], - }, - }, - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def providers(mock_client): - return Providers(mock_client) - - -@pytest.mark.asyncio -class TestProviders: - async def test_get_calls_client_correctly( - self, providers, mock_client, mock_single_response - ): - mock_client._get.return_value = mock_single_response - result = await providers.get(119) - mock_client._get.assert_called_once_with("/providers/119") - assert isinstance(result, ProvidersResponse) - assert len(result.results) == 1 - - async def test_list_with_defaults(self, providers, mock_client, mock_list_response): - mock_client._get.return_value = mock_list_response - result = await providers.list() - params = mock_client._get.call_args[1]["params"] - assert mock_client._get.call_args[0][0] == "/providers" - assert params["page"] == 1 - assert params["limit"] == 1000 - assert isinstance(result, ProvidersResponse) - assert len(result.results) == 1 - - async def test_list_with_pagination( - self, providers, mock_client, mock_list_response - ): - mock_client._get.return_value = mock_list_response - await providers.list(page=3, limit=50) - params = mock_client._get.call_args[1]["params"] - assert params["page"] == 3 - assert params["limit"] == 50 - - @pytest.mark.parametrize( - "sort_order,expected", - [ - ("asc", "asc"), - ("desc", "desc"), - ], - ) - async def test_list_with_sorting( - self, providers, mock_client, mock_list_response, sort_order, expected - ): - mock_client._get.return_value = mock_list_response - await providers.list(order_by="id", sort_order=sort_order) - params = mock_client._get.call_args[1]["params"] - assert params["order_by"] == "id" - assert params["sort_order"] == expected - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_providers_get_throws(self, providers, value): - with pytest.raises(IdentifierOutOfBoundsError): - await providers.get(value) - - @pytest.mark.parametrize( - "parameter,value", - [ - ('page', '1'), - ('limit', '1000'), - ('limit', 9999), - ('iso', 42), - ('iso', True), - ('iso', 'USA'), - ('countries_id', 2**31), - ('countries_id', '999'), - ('countries_id', [1, 2, 3, '4']), - ('countries_id', [1, 2, 3, 2**31]), - ('countries_id', True), - ('parameters_id', 2**31), - ('parameters_id', '999'), - ('parameters_id', [1, 2, 3, '4']), - ('parameters_id', [1, 2, 3, 2**31]), - ('parameters_id', True), - ('sort_order', 'foo'), - ('sort_order', 1), - ('sort_order', False), - ('order_by', 1), - ('order_by', False), - ], - ids=[ - 'page value invalid type', - 'limit value invalid type', - 'limit value out of range', - 'iso invalid type integer', - 'iso invalid type boolean', - 'iso string too many characters', - 'countries_id out of int range', - 'countries_id invalid type, string', - 'countries_id list contains invalid type, string', - 'countries_id list contains int out of range', - 'countries_id invalid type, boolean', - 'parameters_id out of int range', - 'parameters_id invalid type, string', - 'parameters_id list contains invalid type, string', - 'parameters_id list contains int out of range', - 'parameters_id invalid type, boolean', - 'sort_order invalid value, unsupported string', - 'sort_order invalid value int', - 'sort_order invalid value bool', - 'order_by invalid value int', - 'order_by invalid value bool', - ], - ) - async def test_providers_list_throws(self, providers, parameter, value): - mock_params = {parameter: value} - with pytest.raises(InvalidParameterError): - await providers.list(**mock_params) diff --git a/tests/unit/async/resources/test_async_sensors.py b/tests/unit/async/resources/test_async_sensors.py deleted file mode 100644 index aaa8b594..00000000 --- a/tests/unit/async/resources/test_async_sensors.py +++ /dev/null @@ -1,118 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest - -from openaq._async.models.sensors import Sensors -from openaq.shared.exceptions import ( - IdentifierOutOfBoundsError, - InvalidParameterError, -) -from openaq.shared.responses import SensorsResponse - - -@pytest.fixture -def mock_client(): - return AsyncMock() - - -@pytest.fixture -def mock_single_response(): - response = Mock() - response.json.return_value = { - "meta": { - "name": "openaq-api", - "website": "/", - "page": 1, - "limit": 100, - "found": 1, - }, - "results": [ - { - "id": 3920, - "name": "pm25 µg/m³", - "parameter": { - "id": 2, - "name": "pm25", - "units": "µg/m³", - "displayName": "PM2.5", - }, - "datetimeFirst": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - "datetimeLast": { - "utc": "2025-11-26T15:00:00Z", - "local": "2025-11-26T08:00:00-07:00", - }, - "coverage": { - "expectedCount": 1, - "expectedInterval": "01:00:00", - "observedCount": 66561, - "observedInterval": "66561:00:00", - "percentComplete": 6656100, - "percentCoverage": 6656100, - "datetimeFrom": { - "utc": "2016-03-06T20:00:00Z", - "local": "2016-03-06T13:00:00-07:00", - }, - "datetimeTo": { - "utc": "2025-11-26T15:00:00Z", - "local": "2025-11-26T08:00:00-07:00", - }, - }, - "latest": { - "datetime": { - "utc": "2025-11-26T15:00:00Z", - "local": "2025-11-26T08:00:00-07:00", - }, - "value": 4.9, - "coordinates": {"latitude": 35.1353, "longitude": -106.584702}, - }, - "summary": { - "min": -4.9, - "q02": None, - "q25": None, - "median": None, - "q75": None, - "q98": None, - "max": 169.9, - "avg": 5.779898468115439, - "sd": None, - }, - } - ], - } - response.headers = {} - - return response - - -@pytest.fixture -def sensors(mock_client): - return Sensors(mock_client) - - -@pytest.mark.asyncio -class TestSensors: - async def test_get_calls_client_correctly( - self, sensors, mock_client, mock_single_response - ): - mock_client._get.return_value = mock_single_response - result = await sensors.get(3920) - mock_client._get.assert_called_once_with("/sensors/3920") - assert isinstance(result, SensorsResponse) - assert len(result.results) == 1 - - @pytest.mark.parametrize( - "value", - [('42'), (2**31), (-1), (0)], - ids=[ - "invalid, number as string", - "invalid, out of int32 range", - "invalid, negative number", - "invalid, zero", - ], - ) - async def test_get_throws(self, sensors, value): - with pytest.raises(IdentifierOutOfBoundsError): - await sensors.get(value) diff --git a/tests/unit/async/test_async_client.py b/tests/unit/async/test_async_client.py deleted file mode 100644 index 6a5f18f8..00000000 --- a/tests/unit/async/test_async_client.py +++ /dev/null @@ -1,332 +0,0 @@ -import os -import platform -from datetime import datetime -from pathlib import Path -from unittest import mock - -import httpx -import pytest - -from openaq import __version__ -from openaq._async.client import AsyncOpenAQ -from openaq.shared.exceptions import ApiKeyMissingError -from openaq.shared.transport import DEFAULT_LIMITS, DEFAULT_TIMEOUT - -from ..mocks import AsyncMockTransport - -ASYNC_USER_AGENT = f"openaq-python-async-{__version__}-{platform.python_version()}" - - -@pytest.fixture -def mock_config_file(): - mock_toml_content = b"""api-key='test_api_key'""" - with mock.patch.object(Path, 'is_file', return_value=True): - with mock.patch( - 'builtins.open', mock.mock_open(read_data=mock_toml_content) - ) as mock_file: - yield mock_file - - -class TestAsyncClient: - - @pytest.fixture() - def setup(self): - self.client = AsyncOpenAQ( - api_key="abc123-def456-ghi789", transport=AsyncMockTransport - ) - - @pytest.fixture() - def mock_openaq_api_key_env_vars(self): - with mock.patch.dict( - os.environ, {"OPENAQ_API_KEY": "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p"} - ): - yield - - def test_transport_property(self, setup): - assert self.client.transport == AsyncMockTransport - with pytest.raises(AttributeError): - self.client.transport = AsyncMockTransport - - def test_default_client_params(self, setup): - assert self.client._base_url == "https://api.openaq.org/v3/" - - def test_default_headers(self, setup): - assert self.client.headers["User-Agent"] == ASYNC_USER_AGENT - assert self.client.headers["Accept"] == "application/json" - - def test_custom_headers(self, setup): - self.client = AsyncOpenAQ( - api_key="abc123-def456-ghi789", - base_url="https://mycustom.openaq.org", - transport=AsyncMockTransport(), - ) - assert self.client.headers["X-API-Key"] == "abc123-def456-ghi789" - - def test_client_params(self, setup): - self.client = AsyncOpenAQ( - api_key="abc123-def456-ghi789", - base_url="https://mycustom.openaq.org", - transport=AsyncMockTransport(), - ) - assert self.client._base_url == "https://mycustom.openaq.org" - - def test_api_env_var(self, mock_openaq_api_key_env_vars): - """ - tests that api_key is set from environment variable - """ - client = AsyncOpenAQ(transport=AsyncMockTransport) - assert client.api_key == "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p" - - @pytest.mark.usefixtures("mock_config_file") - def test_api_key_from_config(self): - if int(platform.python_version_tuple()[1]) >= 11: - client = AsyncOpenAQ(transport=AsyncMockTransport) - assert client.api_key == "test_api_key" - else: - with pytest.raises(ApiKeyMissingError): - client = AsyncOpenAQ(transport=AsyncMockTransport) - - def test_api_key_arg_override_env_var(self, setup, mock_openaq_api_key_env_vars): - """ - tests that api_key argument overrides api key value set in system environment variable - """ - assert self.client.api_key == "abc123-def456-ghi789" - - def test_api_key_arg_override_config(self, setup, mock_config_file): - """ - tests that api_key argument overrides api key value set in openaq config - """ - assert self.client.api_key == "abc123-def456-ghi789" - - def test_api_key_arg_override_env_vars_config( - self, setup, mock_openaq_api_key_env_vars, mock_config_file - ): - """ - tests that api_key argument overrides api key value set in config file and system environment variable - """ - assert self.client.api_key == "abc123-def456-ghi789" - - @pytest.mark.asyncio - async def test_close_closes_transport(self, setup): - """Test that close() calls transport.close().""" - self.client.transport.close = mock.AsyncMock() - - await self.client.close() - - self.client.transport.close.assert_called_once() - - @pytest.mark.asyncio - async def test_context_manager_enter_returns_client(self, setup): - """Test that __aenter__ returns the client instance.""" - async with self.client as ctx_client: - assert ctx_client is self.client - - @pytest.mark.asyncio - async def test_context_manager_exit_closes_transport(self, setup): - """Test that __aexit__ calls close() which closes transport.""" - self.client.transport.close = mock.AsyncMock() - - async with self.client: - pass - - self.client.transport.close.assert_called_once() - - @pytest.mark.asyncio - async def test_context_manager_exit_closes_even_with_exception(self, setup): - """Test that transport is closed even when exception occurs in context.""" - self.client.transport.close = mock.AsyncMock() - - with pytest.raises(ValueError): - async with self.client: - raise ValueError("Test exception") - - self.client.transport.close.assert_called_once() - - @pytest.mark.asyncio - async def test_acquire_token_grants_when_capacity_available(self, setup): - """Test that token is granted immediately when capacity is available.""" - self.client._rate_limit_remaining = 60.0 - self.client._in_flight_requests = 0 - initial_in_flight = self.client._in_flight_requests - - await self.client._acquire_token() - - assert self.client._in_flight_requests == initial_in_flight + 1 - - @pytest.mark.asyncio - async def test_acquire_token_raises_when_exhausted_and_auto_wait_false(self, setup): - """Test that RateLimitError is raised when exhausted and auto_wait is False.""" - from openaq.shared.exceptions import RateLimitError - - self.client._auto_wait = False - self.client._rate_limit_remaining = 0.0 - self.client._in_flight_requests = 0 - - with pytest.raises(RateLimitError): - await self.client._acquire_token() - - @pytest.mark.asyncio - async def test_acquire_token_resets_capacity_on_new_window(self, setup): - """Test that capacity resets when minute window rolls over.""" - self.client._rate_limit_remaining = 0.0 - self.client._in_flight_requests = 0 - self.client._current_window_id = "202602240000" - - with mock.patch('openaq._async.client.datetime') as mock_datetime: - now = datetime(2026, 2, 24, 0, 1, 0) - mock_datetime.now.return_value = now - - await self.client._acquire_token() - - assert self.client._in_flight_requests == 1 - - @pytest.mark.asyncio - async def test_acquire_token_accounts_for_in_flight_on_window_reset(self, setup): - """Test that in-flight requests are subtracted from capacity on window reset.""" - self.client._rate_limit_remaining = 0.0 - self.client._in_flight_requests = 5 - self.client._current_window_id = "202602240000" - - with mock.patch('openaq._async.client.datetime') as mock_datetime: - now = datetime(2026, 2, 24, 0, 1, 0) - mock_datetime.now.return_value = now - - await self.client._acquire_token() - - assert self.client._rate_limit_remaining == self.client._rate_limit_capacity - 5 - - @pytest.mark.asyncio - async def test_acquire_token_decrements_in_flight_on_completion(self, setup): - """Test that in-flight counter is decremented after request completes.""" - mock_response = mock.MagicMock() - mock_response.headers = {} - self.client.transport.send_request = mock.AsyncMock(return_value=mock_response) - - await self.client._do("get", "/test") - - assert self.client._in_flight_requests == 0 - - @pytest.mark.asyncio - async def test_acquire_token_decrements_in_flight_on_exception(self, setup): - """Test that in-flight counter is decremented even when request raises.""" - self.client.transport.send_request = mock.AsyncMock( - side_effect=Exception("network error") - ) - - with pytest.raises(Exception, match="network error"): - await self.client._do("get", "/test") - - assert self.client._in_flight_requests == 0 - - @pytest.mark.asyncio - async def test_set_rate_limit_updates_remaining_from_headers(self, setup): - """Test that remaining is updated from x-ratelimit-remaining header.""" - headers = httpx.Headers( - {"x-ratelimit-remaining": "45", "x-ratelimit-limit": "60"} - ) - self.client._set_rate_limit(headers) - assert self.client._rate_limit_remaining == 45.0 - - @pytest.mark.asyncio - async def test_set_rate_limit_updates_capacity_from_headers(self, setup): - """Test that capacity is updated from x-ratelimit-limit header.""" - headers = httpx.Headers( - {"x-ratelimit-remaining": "45", "x-ratelimit-limit": "120"} - ) - self.client._set_rate_limit(headers) - assert self.client._rate_limit_capacity == 120.0 - - @pytest.mark.asyncio - async def test_set_rate_limit_server_remaining_overrides_local_count(self, setup): - """Test that server-provided remaining overrides optimistic local tracking.""" - - self.client._rate_limit_remaining = 50.0 - headers = httpx.Headers( - {"x-ratelimit-remaining": "30", "x-ratelimit-limit": "60"} - ) - self.client._set_rate_limit(headers) - assert self.client._rate_limit_remaining == 30.0 - - @pytest.mark.asyncio - async def test_rate_limit_synced_event_set_after_first_request(self, setup): - """Test that rate limit sync event is set after the first request completes.""" - mock_response = mock.MagicMock() - mock_response.headers = {} - self.client.transport.send_request = mock.AsyncMock(return_value=mock_response) - - assert not self.client._rate_limit_synced_event.is_set() - await self.client._do("get", "/test") - assert self.client._rate_limit_synced_event.is_set() - - @pytest.mark.asyncio - async def test_rate_limit_synced_event_set_even_on_request_failure(self, setup): - """Test that sync event is set even when the first request fails.""" - self.client.transport.send_request = mock.AsyncMock( - side_effect=Exception("network error") - ) - - with pytest.raises(Exception): - await self.client._do("get", "/test") - - assert self.client._rate_limit_synced_event.is_set() - - @pytest.mark.asyncio - async def test_default_rate_limit_override(self, setup): - """Test that default rate limit capacity is set to 60.""" - assert self.client._rate_limit_capacity == 60.0 - - @pytest.mark.asyncio - async def test_custom_rate_limit_override(self): - """Test that rate_limit_override sets custom capacity.""" - client = AsyncOpenAQ( - api_key="abc123-def456-ghi789", - transport=AsyncMockTransport(), - rate_limit_override=30, - ) - assert client._rate_limit_capacity == 30.0 - assert client._rate_limit_remaining == 30.0 - - @pytest.mark.asyncio - async def test_blocks_after_custom_limit(self): - """Test that client raises after exhausting custom rate limit.""" - from openaq.shared.exceptions import RateLimitError - - client = AsyncOpenAQ( - api_key="abc123-def456-ghi789", - transport=AsyncMockTransport(), - auto_wait=False, - rate_limit_override=5, - ) - for _ in range(5): - await client._acquire_token() - - with pytest.raises(RateLimitError): - await client._acquire_token() - - def test_default_timeout_applied_to_transport(self): - """Test that default timeout is applied to the transport.""" - client = AsyncOpenAQ(api_key="abc123-def456-ghi789") - assert client.transport.client.timeout == DEFAULT_TIMEOUT - - def test_custom_timeout_passed_to_transport(self): - """Test that a custom timeout is passed through to the transport.""" - custom_timeout = httpx.Timeout(10.0, read=15.0) - client = AsyncOpenAQ(api_key="abc123-def456-ghi789", timeout=custom_timeout) - assert client.transport.client.timeout == custom_timeout - - def test_default_limits_applied_to_transport(self): - """Test that default connection limits are applied to the transport.""" - with mock.patch('openaq._async.transport.httpx.AsyncClient') as mock_client: - AsyncOpenAQ(api_key="abc123-def456-ghi789") - mock_client.assert_called_once_with( - timeout=DEFAULT_TIMEOUT, limits=DEFAULT_LIMITS - ) - - def test_custom_limits_passed_to_transport(self): - """Test that custom connection limits are passed through to the transport.""" - custom_limits = httpx.Limits(max_connections=5, max_keepalive_connections=2) - with mock.patch('openaq._async.transport.httpx.AsyncClient') as mock_client: - AsyncOpenAQ(api_key="abc123-def456-ghi789", limits=custom_limits) - mock_client.assert_called_once_with( - timeout=DEFAULT_TIMEOUT, limits=custom_limits - ) diff --git a/tests/unit/async/test_async_transport.py b/tests/unit/async/test_async_transport.py deleted file mode 100644 index 4c230bf8..00000000 --- a/tests/unit/async/test_async_transport.py +++ /dev/null @@ -1,67 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - -from openaq._async.transport import AsyncTransport - - -def request_matches(expected_request): - def matcher(request): - return ( - request.method == expected_request.method - and request.url == expected_request.url - and request.headers == expected_request.headers - ) - - return matcher - - -@pytest.mark.asyncio -@patch('httpx.AsyncClient') -async def test_send_request(mock_httpx_async_client): - mock_client_instance = mock_httpx_async_client.return_value - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - - # Use AsyncMock for async method - mock_client_instance.send = AsyncMock(return_value=mock_response) - - with patch('openaq._async.transport.check_response') as mock_check_response: - mock_check_response.return_value = 'processed_response' - - transport = AsyncTransport() - - result = await transport.send_request( - method='GET', - url='https://api.openaq.org/v3/locations', - params={'limit': '100'}, - headers={'x-api-key': 'foobar'}, - ) - - mock_httpx_async_client.assert_called_once() - expected_request = httpx.Request( - method='GET', - url='https://api.openaq.org/v3/locations', - params={'limit': '100'}, - headers={'x-api-key': 'foobar'}, - ) - mock_client_instance.send.assert_called_once() - actual_request = mock_client_instance.send.call_args[0][0] - assert request_matches(expected_request)(actual_request) - mock_check_response.assert_called_once_with(mock_response) - assert result == 'processed_response' - - -@pytest.mark.asyncio -@patch('httpx.AsyncClient') -async def test_close(mock_httpx_async_client): - mock_client_instance = mock_httpx_async_client.return_value - # Use AsyncMock for async method - mock_client_instance.aclose = AsyncMock() - - transport = AsyncTransport() - await transport.close() - - mock_client_instance.aclose.assert_called_once() diff --git a/tests/unit/mocks.py b/tests/unit/mocks.py index 1504f534..06fc5c90 100644 --- a/tests/unit/mocks.py +++ b/tests/unit/mocks.py @@ -2,12 +2,13 @@ from typing import Any, Mapping from unittest import mock -import httpx import pytest +from openaq.core.transport import Response + class MockTransport: - def __init__(self, response: httpx.Response = None): + def __init__(self, response: Response | None = None) -> None: self.response = response def send_request( @@ -16,30 +17,14 @@ def send_request( url: str, params: Mapping[str, str], headers: Mapping[str, Any], - ): - return self.response - - def close(self): ... - - -class AsyncMockTransport: - def __init__(self, response: httpx.Response = None): - self.response = response - - async def send_request( - self, - method: str, - url: str, - params: Mapping[str, str], - headers: Mapping[str, Any], - ): + ) -> Response: return self.response - async def close(self): ... + def close(self) -> None: ... @pytest.fixture(scope='class') -def mock_openaq_api_key_env_vars(scope='class'): +def mock_openaq_api_key_env_vars(): with mock.patch.dict( os.environ, {"OPENAQ_API_KEY": "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p"} ): diff --git a/tests/unit/sync/__init__.py b/tests/unit/sync/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/sync/test_transport.py b/tests/unit/sync/test_transport.py deleted file mode 100644 index 17439bcc..00000000 --- a/tests/unit/sync/test_transport.py +++ /dev/null @@ -1,59 +0,0 @@ -from unittest.mock import MagicMock, patch - -import httpx - -from openaq._sync.transport import Transport - - -def request_matches(expected_request): - def matcher(request): - return ( - request.method == expected_request.method - and request.url == expected_request.url - and request.headers == expected_request.headers - ) - - return matcher - - -@patch('httpx.Client') -def test_send_request(mock_httpx_client): - mock_client_instance = mock_httpx_client.return_value - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - - mock_client_instance.send.return_value = mock_response - - with patch('openaq._sync.transport.check_response') as mock_check_response: - mock_check_response.return_value = 'processed_response' - - transport = Transport() - - result = transport.send_request( - method='GET', - url='https://api.openaq.org/v3/locations', - params={'limit': '100'}, - headers={'x-api-key': 'foobar'}, - ) - - mock_httpx_client.assert_called_once() - expected_request = httpx.Request( - method='GET', - url='https://api.openaq.org/v3/locations', - params={'limit': '100'}, - headers={'x-api-key': 'foobar'}, - ) - mock_client_instance.send.assert_called_once() - actual_request = mock_client_instance.send.call_args[0][0] - assert request_matches(expected_request)(actual_request) - mock_check_response.assert_called_once_with(mock_response) - assert result == 'processed_response' - - -@patch('httpx.Client') -def test_close(mock_httpx_client): - mock_client_instance = mock_httpx_client.return_value - transport = Transport() - transport.close() - mock_client_instance.close.assert_called_once() diff --git a/tests/unit/sync/test_sync_client.py b/tests/unit/test_client.py similarity index 50% rename from tests/unit/sync/test_sync_client.py rename to tests/unit/test_client.py index 39e88093..3bc171fc 100644 --- a/tests/unit/sync/test_sync_client.py +++ b/tests/unit/test_client.py @@ -2,28 +2,33 @@ import platform from datetime import datetime, timedelta from pathlib import Path -from unittest import mock +from unittest.mock import MagicMock, Mock, mock_open, patch -import httpx import pytest from freezegun import freeze_time from openaq import __version__ -from openaq._sync.client import OpenAQ -from openaq.shared.exceptions import ApiKeyMissingError, RateLimitError -from openaq.shared.transport import DEFAULT_LIMITS, DEFAULT_TIMEOUT +from openaq.client import OpenAQ, _get_openaq_config, _has_toml +from openaq.core.exceptions import ApiKeyMissingError, RateLimitError +from openaq.core.transport import ( + DEFAULT_LIMITS, + DEFAULT_TIMEOUT, + Headers, + Limits, + Timeout, +) -from ..mocks import MockTransport +from .mocks import MockTransport -SYNC_USER_AGENT = f"openaq-python-sync-{__version__}-{platform.python_version()}" +USER_AGENT = f"openaq-python-{__version__}-{platform.python_version()}" @pytest.fixture def mock_config_file(): mock_toml_content = b"""api-key='test_api_key'""" - with mock.patch.object(Path, 'is_file', return_value=True): - with mock.patch( - 'builtins.open', mock.mock_open(read_data=mock_toml_content) + with patch.object(Path, 'is_file', return_value=True): + with patch( + 'builtins.open', mock_open(read_data=mock_toml_content) ) as mock_file: yield mock_file @@ -31,25 +36,25 @@ def mock_config_file(): class TestClient: @pytest.fixture() def setup(self): - self.client = OpenAQ(api_key="abc123-def456-ghi789", transport=MockTransport) + self.client = OpenAQ(api_key="abc123-def456-ghi789", transport=MockTransport()) @pytest.fixture() def mock_openaq_api_key_env_vars(self): - with mock.patch.dict( + with patch.dict( os.environ, {"OPENAQ_API_KEY": "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p"} ): yield def test_transport_property(self, setup): - assert self.client.transport == MockTransport + assert isinstance(self.client.transport, MockTransport) with pytest.raises(AttributeError): - self.client.transport = MockTransport + self.client.transport = MockTransport() def test_default_client_params(self, setup): assert self.client._base_url == "https://api.openaq.org/v3/" def test_default_headers(self, setup): - assert self.client.headers["User-Agent"] == SYNC_USER_AGENT + assert self.client.headers["User-Agent"] == USER_AGENT assert self.client.headers["Accept"] == "application/json" def test_custom_headers(self, setup): @@ -69,52 +74,37 @@ def test_client_params(self, setup): assert self.client._base_url == "https://mycustom.openaq.org" def test_api_env_var(self, mock_openaq_api_key_env_vars): - """ - tests that api_key is set from environment variable - """ - client = OpenAQ(transport=MockTransport) + client = OpenAQ(transport=MockTransport()) assert client.api_key == "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p" @pytest.mark.usefixtures("mock_config_file") def test_api_key_from_config(self): if int(platform.python_version_tuple()[1]) >= 11: - client = OpenAQ(transport=MockTransport) + client = OpenAQ(transport=MockTransport()) assert client.api_key == "test_api_key" else: with pytest.raises(ApiKeyMissingError): - client = OpenAQ(transport=MockTransport) + client = OpenAQ(transport=MockTransport()) def test_api_key_arg_override_env_var(self, setup, mock_openaq_api_key_env_vars): - """ - tests that api_key argument overrides api key value set in system environment variable - """ assert self.client.api_key == "abc123-def456-ghi789" - def test_api_key_arg_override_env_var(self, setup, mock_config_file): - """ - tests that api_key argument overrides api key value set in openaq config - """ + def test_api_key_arg_override_config(self, setup, mock_config_file): assert self.client.api_key == "abc123-def456-ghi789" def test_api_key_arg_override_env_vars_config( self, setup, mock_openaq_api_key_env_vars, mock_config_file ): - """ - tests that api_key argument overrides api key value set in config file and system environment variable - """ assert self.client.api_key == "abc123-def456-ghi789" - @mock.patch('openaq._sync.client.datetime') - @mock.patch('time.sleep') - @mock.patch('openaq._sync.client.logger') + @patch('openaq.client.datetime') + @patch('time.sleep') + @patch('openaq.client.logger') def test_wait_for_rate_limit_reset_waits_when_positive( self, mock_logger, mock_sleep, mock_datetime, setup ): - """Test that sleep is called with correct duration when wait_seconds > 0.""" now = datetime(2026, 2, 12, 0, 0, 0) mock_datetime.now.return_value = now - - # Set reset time to 5 seconds in the future self.client._rate_limit_reset_datetime = now + timedelta(seconds=5) self.client._wait_for_rate_limit_reset() @@ -124,17 +114,14 @@ def test_wait_for_rate_limit_reset_waits_when_positive( "Rate limit hit. Waiting 5 seconds for reset." ) - @mock.patch('openaq._sync.client.datetime') - @mock.patch('time.sleep') - @mock.patch('openaq._sync.client.logger') + @patch('openaq.client.datetime') + @patch('time.sleep') + @patch('openaq.client.logger') def test_wait_for_rate_limit_reset_does_not_wait_when_zero( self, mock_logger, mock_sleep, mock_datetime, setup ): - """Test that sleep is not called when wait_seconds is 0.""" now = datetime(2026, 2, 12, 0, 0, 0) mock_datetime.now.return_value = now - - # Set reset time to now (0 seconds wait) self.client._rate_limit_reset_datetime = now self.client._wait_for_rate_limit_reset() @@ -142,17 +129,14 @@ def test_wait_for_rate_limit_reset_does_not_wait_when_zero( mock_sleep.assert_not_called() mock_logger.info.assert_not_called() - @mock.patch('openaq._sync.client.datetime') - @mock.patch('time.sleep') - @mock.patch('openaq._sync.client.logger') + @patch('openaq.client.datetime') + @patch('time.sleep') + @patch('openaq.client.logger') def test_wait_for_rate_limit_reset_does_not_wait_when_negative( self, mock_logger, mock_sleep, mock_datetime, setup ): - """Test that sleep is not called when wait_seconds is negative.""" now = datetime(2026, 2, 12, 0, 0, 0) mock_datetime.now.return_value = now - - # Set reset time to 5 seconds in the past self.client._rate_limit_reset_datetime = now - timedelta(seconds=5) self.client._wait_for_rate_limit_reset() @@ -161,58 +145,42 @@ def test_wait_for_rate_limit_reset_does_not_wait_when_negative( mock_logger.info.assert_not_called() def test_close_closes_transport(self, setup): - """Test that close() calls transport.close().""" - self.client.transport.close = mock.Mock() - + self.client._transport.close = Mock() self.client.close() - - self.client.transport.close.assert_called_once() + self.client._transport.close.assert_called_once() def test_context_manager_enter_returns_client(self, setup): - """Test that __enter__ returns the client instance.""" with self.client as ctx_client: assert ctx_client is self.client def test_context_manager_exit_closes_transport(self, setup): - """Test that __exit__ calls close() which closes transport.""" - self.client.transport.close = mock.Mock() - + self.client._transport.close = Mock() with self.client: pass - - self.client.transport.close.assert_called_once() + self.client._transport.close.assert_called_once() def test_context_manager_exit_closes_even_with_exception(self, setup): - """Test that transport is closed even when exception occurs in context.""" - self.client.transport.close = mock.Mock() - + self.client._transport.close = Mock() with pytest.raises(ValueError): with self.client: raise ValueError("Test exception") - - self.client.transport.close.assert_called_once() + self.client._transport.close.assert_called_once() def test_blocks_after_custom_limit(self): - """Test that client blocks after exhausting custom rate limit.""" - from openaq.shared.exceptions import RateLimitError - client = OpenAQ( api_key="abc123-def456-ghi789", transport=MockTransport(), auto_wait=False, rate_limit_override=5, ) + client._rate_limit_reset_datetime = datetime.now() + timedelta(seconds=60) for _ in range(5): client._check_rate_limit() - client._rate_limit_remaining -= 1 with pytest.raises(RateLimitError): client._check_rate_limit() def test_allows_exactly_override_requests(self): - """Test that client allows exactly rate_limit_override requests before blocking.""" - from openaq.shared.exceptions import RateLimitError - limit = 10 client = OpenAQ( api_key="abc123-def456-ghi789", @@ -220,11 +188,11 @@ def test_allows_exactly_override_requests(self): auto_wait=False, rate_limit_override=limit, ) + client._rate_limit_reset_datetime = datetime.now() + timedelta(seconds=60) success_count = 0 for _ in range(limit + 5): try: client._check_rate_limit() - client._rate_limit_remaining -= 1 success_count += 1 except RateLimitError: break @@ -232,9 +200,6 @@ def test_allows_exactly_override_requests(self): assert success_count == limit def test_raises_when_exhausted_and_auto_wait_false(self, setup): - """Test that RateLimitError is raised when exhausted and auto_wait is False.""" - from openaq.shared.exceptions import RateLimitError - self.client._auto_wait = False self.client._rate_limit_remaining = 0.0 self.client._rate_limit_reset_datetime = datetime.now() + timedelta(seconds=30) @@ -244,18 +209,15 @@ def test_raises_when_exhausted_and_auto_wait_false(self, setup): @freeze_time("2026-02-12T00:00:00") def test_error_message_includes_reset_seconds(self, setup): - """Test that RateLimitError message includes seconds until reset.""" self.client._auto_wait = False self.client._rate_limit_remaining = 0.0 self.client._rate_limit_reset_datetime = datetime(2026, 2, 12, 0, 0, 30) - self.client._current_window_id = datetime.now().strftime("%Y%m%d%H%M") with pytest.raises(RateLimitError, match="30"): self.client._check_rate_limit() - @mock.patch('time.sleep') + @patch('time.sleep') def test_waits_when_exhausted_and_auto_wait_true(self, mock_sleep, setup): - """Test that sleep is called when exhausted and auto_wait is True.""" self.client._auto_wait = True self.client._rate_limit_remaining = 0.0 self.client._rate_limit_reset_datetime = datetime.now() + timedelta(seconds=10) @@ -264,78 +226,29 @@ def test_waits_when_exhausted_and_auto_wait_true(self, mock_sleep, setup): mock_sleep.assert_called_once() - @mock.patch('time.sleep') + @patch('time.sleep') def test_resets_capacity_after_wait(self, mock_sleep, setup): - """Test that remaining is reset to full capacity after waiting.""" self.client._auto_wait = True self.client._rate_limit_remaining = 0.0 self.client._rate_limit_reset_datetime = datetime.now() + timedelta(seconds=10) self.client._check_rate_limit() - assert self.client._rate_limit_remaining == self.client._rate_limit_capacity - - @mock.patch('openaq._sync.client.datetime') - def test_resets_capacity_on_new_window(self, mock_datetime, setup): - """Test that capacity resets automatically when minute window rolls over.""" - now = datetime(2026, 2, 12, 0, 1, 0) - mock_datetime.now.return_value = now - - self.client._rate_limit_remaining = 0.0 - self.client._current_window_id = "202602120000" - - try: - self.client._check_rate_limit() - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") - - assert self.client._rate_limit_remaining == self.client._rate_limit_capacity - - @mock.patch('openaq._sync.client.datetime') - def test_does_not_reset_capacity_within_same_window(self, mock_datetime, setup): - """Test that capacity is not reset when still within the same window.""" - now = datetime(2026, 2, 12, 0, 0, 30) - mock_datetime.now.return_value = now - self.client._current_window_id = now.strftime("%Y%m%d%H%M") - self.client._rate_limit_remaining = 5.0 - - self.client._check_rate_limit() - - assert self.client._rate_limit_remaining == 5.0 - - @mock.patch('openaq._sync.client.datetime') - def test_updates_window_id_on_rollover(self, mock_datetime, setup): - """Test that window ID is updated when minute window rolls over.""" - now = datetime(2026, 2, 12, 0, 1, 0) - mock_datetime.now.return_value = now - self.client._current_window_id = "202602120000" - - self.client._check_rate_limit() - - assert self.client._current_window_id == "202602120001" + assert self.client._rate_limit_remaining == self.client._rate_limit_capacity - 1 def test_set_rate_limit_updates_remaining_from_headers(self, setup): - """Test that remaining is updated from x-ratelimit-remaining header.""" - headers = httpx.Headers( - {"x-ratelimit-remaining": "45", "x-ratelimit-reset": "60"} - ) + headers = Headers({"x-ratelimit-remaining": "45", "x-ratelimit-reset": "60"}) self.client._set_rate_limit(headers) assert self.client._rate_limit_remaining == 45 def test_set_rate_limit_server_remaining_overrides_local_count(self, setup): - """Test that server-provided remaining overrides optimistic local tracking.""" self.client._rate_limit_remaining = 50.0 - headers = httpx.Headers( - {"x-ratelimit-remaining": "30", "x-ratelimit-reset": "60"} - ) + headers = Headers({"x-ratelimit-remaining": "30", "x-ratelimit-reset": "60"}) self.client._set_rate_limit(headers) assert self.client._rate_limit_remaining == 30 def test_set_rate_limit_updates_reset_datetime_from_headers(self, setup): - """Test that reset datetime is calculated correctly from x-ratelimit-reset header.""" - headers = httpx.Headers( - {"x-ratelimit-remaining": "45", "x-ratelimit-reset": "30"} - ) + headers = Headers({"x-ratelimit-remaining": "45", "x-ratelimit-reset": "30"}) before = datetime.now() + timedelta(seconds=29) self.client._set_rate_limit(headers) after = datetime.now() + timedelta(seconds=32) @@ -343,119 +256,122 @@ def test_set_rate_limit_updates_reset_datetime_from_headers(self, setup): assert before < self.client._rate_limit_reset_datetime < after def test_set_rate_limit_missing_headers_does_not_raise(self, setup): - """Test that entirely missing rate limit headers are handled gracefully.""" - headers = httpx.Headers({}) + headers = Headers({}) try: self.client._set_rate_limit(headers) except Exception as e: pytest.fail(f"Unexpected exception raised: {e}") def test_set_rate_limit_remaining_allows_further_requests(self, setup): - """Test that after server sync, requests are allowed up to the new remaining count.""" - from openaq.shared.exceptions import RateLimitError - - headers = httpx.Headers( - {"x-ratelimit-remaining": "2", "x-ratelimit-reset": "30"} - ) + headers = Headers({"x-ratelimit-remaining": "2", "x-ratelimit-reset": "30"}) self.client._set_rate_limit(headers) self.client._auto_wait = False self.client._check_rate_limit() - self.client._rate_limit_remaining -= 1 self.client._check_rate_limit() - self.client._rate_limit_remaining -= 1 with pytest.raises(RateLimitError): self.client._check_rate_limit() def test_do_calls_transport_with_correct_args(self, setup): - """Test that _do constructs the correct URL and passes method to transport.""" - mock_response = mock.MagicMock() - mock_response.headers = httpx.Headers({}) - self.client.transport.send_request = mock.Mock(return_value=mock_response) + mock_response = MagicMock() + mock_response.headers = Headers({}) + self.client._transport.send_request = Mock(return_value=mock_response) self.client._do("get", "locations/1") - call_kwargs = self.client.transport.send_request.call_args + call_kwargs = self.client._transport.send_request.call_args assert call_kwargs.kwargs["url"] == "https://api.openaq.org/v3/locations/1" assert call_kwargs.kwargs["method"] == "get" def test_do_passes_params_to_transport(self, setup): - """Test that _do passes query params through to the transport.""" - mock_response = mock.MagicMock() - mock_response.headers = httpx.Headers({}) - self.client.transport.send_request = mock.Mock(return_value=mock_response) + mock_response = MagicMock() + mock_response.headers = Headers({}) + self.client._transport.send_request = Mock(return_value=mock_response) self.client._do("get", "/test", params={"limit": 100, "page": 1}) - call_kwargs = self.client.transport.send_request.call_args + call_kwargs = self.client._transport.send_request.call_args assert call_kwargs.kwargs["params"] == {"limit": 100, "page": 1} def test_do_passes_custom_headers_to_transport(self, setup): - """Test that _do merges custom headers into the request.""" - mock_response = mock.MagicMock() - mock_response.headers = httpx.Headers({}) - self.client.transport.send_request = mock.Mock(return_value=mock_response) + mock_response = MagicMock() + mock_response.headers = Headers({}) + self.client._transport.send_request = Mock(return_value=mock_response) self.client._do("get", "/test", headers={"X-Custom-Header": "value"}) - call_kwargs = self.client.transport.send_request.call_args - assert "X-Custom-Header" in call_kwargs.kwargs["headers"] + call_kwargs = self.client._transport.send_request.call_args + assert "x-custom-header" in call_kwargs.kwargs["headers"] def test_do_syncs_rate_limit_from_response_headers(self, setup): - """Test that _do updates rate limit state from response headers.""" - mock_response = mock.MagicMock() - mock_response.headers = httpx.Headers( - { - "x-ratelimit-remaining": "42", - "x-ratelimit-reset": "30", - } + mock_response = MagicMock() + mock_response.headers = Headers( + {"x-ratelimit-remaining": "42", "x-ratelimit-reset": "30"} ) - self.client.transport.send_request = mock.Mock(return_value=mock_response) + self.client._transport.send_request = Mock(return_value=mock_response) self.client._do("get", "/test") assert self.client._rate_limit_remaining == 42 def test_do_raises_before_sending_when_rate_limited(self, setup): - """Test that _do raises RateLimitError without calling transport when exhausted.""" - from openaq.shared.exceptions import RateLimitError - self.client._auto_wait = False self.client._rate_limit_remaining = 0 self.client._rate_limit_reset_datetime = datetime.now() + timedelta(seconds=30) - self.client._current_window_id = datetime.now().strftime("%Y%m%d%H%M") - self.client.transport.send_request = mock.Mock() + self.client._transport.send_request = Mock() with pytest.raises(RateLimitError): self.client._do("get", "/test") - self.client.transport.send_request.assert_not_called() + self.client._transport.send_request.assert_not_called() def test_default_timeout_applied_to_transport(self): - """Test that default timeout is applied to the transport.""" client = OpenAQ(api_key="abc123-def456-ghi789") - assert client.transport.client.timeout == DEFAULT_TIMEOUT + assert client.transport._connect_timeout == DEFAULT_TIMEOUT.connect + assert client.transport._read_timeout == DEFAULT_TIMEOUT.read def test_custom_timeout_passed_to_transport(self): - """Test that a custom timeout is passed through to the transport.""" - custom_timeout = httpx.Timeout(10.0, read=15.0) + custom_timeout = Timeout(10.0, read=15.0) client = OpenAQ(api_key="abc123-def456-ghi789", timeout=custom_timeout) - assert client.transport.client.timeout == custom_timeout + assert client.transport._connect_timeout == custom_timeout.connect + assert client.transport._read_timeout == custom_timeout.read def test_default_limits_applied_to_transport(self): - """Test that default connection limits are applied to the transport.""" - with mock.patch('openaq._sync.transport.httpx.Client') as mock_client: - OpenAQ(api_key="abc123-def456-ghi789") - mock_client.assert_called_once_with( - timeout=DEFAULT_TIMEOUT, limits=DEFAULT_LIMITS - ) + client = OpenAQ(api_key="abc123-def456-ghi789") + assert client.transport._pool._max_total == DEFAULT_LIMITS.max_connections + assert ( + client.transport._pool._max_idle == DEFAULT_LIMITS.max_keepalive_connections + ) def test_custom_limits_passed_to_transport(self): - """Test that custom connection limits are passed through to the transport.""" - custom_limits = httpx.Limits(max_connections=5, max_keepalive_connections=2) - with mock.patch('openaq._sync.transport.httpx.Client') as mock_client: - OpenAQ(api_key="abc123-def456-ghi789", limits=custom_limits) - mock_client.assert_called_once_with( - timeout=DEFAULT_TIMEOUT, limits=custom_limits - ) + custom_limits = Limits(max_connections=5, max_keepalive_connections=2) + client = OpenAQ(api_key="abc123-def456-ghi789", limits=custom_limits) + assert client.transport._pool._max_total == 5 + assert client.transport._pool._max_idle == 2 + + +def test_tomllib_conditional_import(): + if int(platform.python_version_tuple()[1]) >= 11: + assert _has_toml == True + else: + assert _has_toml == False + + +def test__get_openaq_config_file_exists(): + mock_toml_content = b""" + api-key = 'openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p' + """ + expected_config = {"api_key": "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p"} + + with patch.object(Path, 'is_file', return_value=True): + with patch( + 'builtins.open', mock_open(read_data=mock_toml_content) + ) as mock_file: + result = None + if int(platform.python_version_tuple()[1]) >= 11: + result = _get_openaq_config() + assert result == expected_config + mock_file.assert_any_call(Path(Path.home() / ".openaq.toml"), 'rb') + else: + assert result == None diff --git a/tests/unit/sync/resources/test_countries.py b/tests/unit/test_countries.py similarity index 98% rename from tests/unit/sync/resources/test_countries.py rename to tests/unit/test_countries.py index 6ddc6121..8f60beeb 100644 --- a/tests/unit/sync/resources/test_countries.py +++ b/tests/unit/test_countries.py @@ -2,12 +2,12 @@ import pytest -from openaq._sync.models.countries import Countries -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import CountriesResponse +from openaq.core.responses import CountriesResponse +from openaq.models.countries import Countries @pytest.fixture diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 1ef6382a..212f99f8 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,7 +1,8 @@ -import httpx +import http.client + import pytest -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( BadGatewayError, BadRequestError, ForbiddenError, @@ -14,7 +15,12 @@ TimeoutError, ValidationError, ) -from openaq.shared.transport import check_response +from openaq.core.transport import Response, check_response + + +def make_response(status_code: int) -> Response: + msg = http.client.HTTPMessage() + return Response(status_code, b"", msg) @pytest.mark.parametrize( @@ -35,12 +41,11 @@ ], ) def test_check_response(http_code, exception): - response = httpx.Response(http_code) with pytest.raises(exception): - check_response(response) + check_response(make_response(http_code)) @pytest.mark.parametrize("http_code", [200, 201, 202, 204]) def test_check_response_successful(http_code): - response = httpx.Response(http_code) + response = make_response(http_code) assert check_response(response) == response diff --git a/tests/unit/sync/resources/test_instruments.py b/tests/unit/test_instruments.py similarity index 96% rename from tests/unit/sync/resources/test_instruments.py rename to tests/unit/test_instruments.py index 45102e65..9079bb8c 100644 --- a/tests/unit/sync/resources/test_instruments.py +++ b/tests/unit/test_instruments.py @@ -2,12 +2,12 @@ import pytest -from openaq._sync.models.instruments import Instruments -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import InstrumentsResponse +from openaq.core.responses import InstrumentsResponse +from openaq.models.instruments import Instruments @pytest.fixture diff --git a/tests/unit/sync/resources/test_licenses.py b/tests/unit/test_licenses.py similarity index 97% rename from tests/unit/sync/resources/test_licenses.py rename to tests/unit/test_licenses.py index 25e097e6..b4156321 100644 --- a/tests/unit/sync/resources/test_licenses.py +++ b/tests/unit/test_licenses.py @@ -2,12 +2,12 @@ import pytest -from openaq._sync.models.licenses import Licenses -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import LicensesResponse +from openaq.core.responses import LicensesResponse +from openaq.models.licenses import Licenses @pytest.fixture diff --git a/tests/unit/sync/resources/test_locations.py b/tests/unit/test_locations.py similarity index 99% rename from tests/unit/sync/resources/test_locations.py rename to tests/unit/test_locations.py index 434735df..8ee4b652 100644 --- a/tests/unit/sync/resources/test_locations.py +++ b/tests/unit/test_locations.py @@ -2,16 +2,16 @@ import pytest -from openaq._sync.models.locations import Locations -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import ( +from openaq.core.responses import ( LatestResponse, LocationsResponse, SensorsResponse, ) +from openaq.models.locations import Locations @pytest.fixture diff --git a/tests/unit/sync/resources/test_manufacturers.py b/tests/unit/test_manufacturers.py similarity index 97% rename from tests/unit/sync/resources/test_manufacturers.py rename to tests/unit/test_manufacturers.py index 5782f1ac..4b05435f 100644 --- a/tests/unit/sync/resources/test_manufacturers.py +++ b/tests/unit/test_manufacturers.py @@ -2,12 +2,12 @@ import pytest -from openaq._sync.models.manufacturers import Manufacturers -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import InstrumentsResponse, ManufacturersResponse +from openaq.core.responses import InstrumentsResponse, ManufacturersResponse +from openaq.models.manufacturers import Manufacturers @pytest.fixture diff --git a/tests/unit/sync/resources/test_measurements.py b/tests/unit/test_measurements.py similarity index 98% rename from tests/unit/sync/resources/test_measurements.py rename to tests/unit/test_measurements.py index 0fd9dfd7..97bdba32 100644 --- a/tests/unit/sync/resources/test_measurements.py +++ b/tests/unit/test_measurements.py @@ -3,12 +3,12 @@ import pytest -from openaq._sync.models.measurements import Measurements -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import MeasurementsResponse +from openaq.core.responses import MeasurementsResponse +from openaq.models.measurements import Measurements @pytest.fixture diff --git a/tests/unit/sync/resources/test_owners.py b/tests/unit/test_owners.py similarity index 96% rename from tests/unit/sync/resources/test_owners.py rename to tests/unit/test_owners.py index 50c915ea..13f8f4f0 100644 --- a/tests/unit/sync/resources/test_owners.py +++ b/tests/unit/test_owners.py @@ -2,12 +2,12 @@ import pytest -from openaq._sync.models.owners import Owners -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import OwnersResponse +from openaq.core.responses import OwnersResponse +from openaq.models.owners import Owners @pytest.fixture diff --git a/tests/unit/sync/resources/test_parameters.py b/tests/unit/test_parameters.py similarity index 97% rename from tests/unit/sync/resources/test_parameters.py rename to tests/unit/test_parameters.py index 4ac0a833..55a9bca0 100644 --- a/tests/unit/sync/resources/test_parameters.py +++ b/tests/unit/test_parameters.py @@ -2,12 +2,12 @@ import pytest -from openaq._sync.models.parameters import Parameters -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import LatestResponse, ParametersResponse +from openaq.core.responses import LatestResponse, ParametersResponse +from openaq.models.parameters import Parameters @pytest.fixture diff --git a/tests/unit/sync/resources/test_providers.py b/tests/unit/test_providers.py similarity index 98% rename from tests/unit/sync/resources/test_providers.py rename to tests/unit/test_providers.py index 6b7e1e3d..51f3974d 100644 --- a/tests/unit/sync/resources/test_providers.py +++ b/tests/unit/test_providers.py @@ -2,12 +2,12 @@ import pytest -from openaq._sync.models.providers import Providers -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import ProvidersResponse +from openaq.core.responses import ProvidersResponse +from openaq.models.providers import Providers @pytest.fixture diff --git a/tests/unit/sync/resources/test_sensors.py b/tests/unit/test_sensors.py similarity index 96% rename from tests/unit/sync/resources/test_sensors.py rename to tests/unit/test_sensors.py index fc11cb9e..2c5cedca 100644 --- a/tests/unit/sync/resources/test_sensors.py +++ b/tests/unit/test_sensors.py @@ -2,12 +2,12 @@ import pytest -from openaq._sync.models.sensors import Sensors -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.responses import SensorsResponse +from openaq.core.responses import SensorsResponse +from openaq.models.sensors import Sensors @pytest.fixture diff --git a/tests/unit/test_shared_client.py b/tests/unit/test_shared_client.py deleted file mode 100644 index 7c3ccce6..00000000 --- a/tests/unit/test_shared_client.py +++ /dev/null @@ -1,181 +0,0 @@ -import os -import platform -from http import HTTPStatus -from pathlib import Path -from unittest import mock -from unittest.mock import mock_open, patch - -import httpx -import pytest - -from openaq import __version__ -from openaq.shared.client import BaseClient, _get_openaq_config, _has_toml -from openaq.shared.exceptions import ApiKeyMissingError -from tests.unit.mocks import MockTransport - - -def test_tomllib_conditional_import(): - if int(platform.python_version_tuple()[1]) >= 11: - assert _has_toml == True - else: - assert _has_toml == False - - -def test__get_openaq_config_file_exists(): - mock_toml_content = b""" - api-key = 'openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p' - """ - expected_config = {"api_key": "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p"} - - with patch.object(Path, 'is_file', return_value=True): - with patch( - 'builtins.open', mock_open(read_data=mock_toml_content) - ) as mock_file: - result = None - if int(platform.python_version_tuple()[1]) >= 11: - result = _get_openaq_config() - assert result == expected_config - mock_file.assert_any_call(Path(Path.home() / ".openaq.toml"), 'rb') - else: - assert result == None - - -DEFAULT_USER_AGENT = f"openaq-python-{__version__}-{platform.python_version()}" - - -class SharedClient(BaseClient): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._user_agent = DEFAULT_USER_AGENT - self.resolve_headers() - - def close(): - pass - - def _do(): - pass - - def _get(): - pass - - def _set_rate_limit(self, headers): - pass - - -@pytest.fixture -def mock_config_file(): - mock_toml_content = b"""api-key='test_api_key'""" - with mock.patch.object(Path, 'is_file', return_value=True): - with mock.patch( - 'builtins.open', mock.mock_open(read_data=mock_toml_content) - ) as mock_file: - yield mock_file - - -class TestSharedClient: - @pytest.fixture(autouse=True) - def setup(self): - self.instance = SharedClient( - MockTransport, - api_key='abc123-def456-ghi789', - headers={'this': 'that'}, - auto_wait=False, - ) - - @pytest.mark.usefixtures("mock_config_file") - def test__get_api_key(self): - if int(platform.python_version_tuple()[1]) >= 11: - assert self.instance._get_api_key() == 'test_api_key' - else: - assert self.instance._get_api_key() == None - - def test_no_api_key_throws(self): - with pytest.raises(ApiKeyMissingError): - instance = SharedClient(MockTransport) - - @pytest.fixture() - def mock_openaq_api_key_env_vars(self): - with mock.patch.dict( - os.environ, {"OPENAQ_API_KEY": "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p"} - ): - yield - - def test_api_env_var(self, mock_openaq_api_key_env_vars): - """ - tests that api_key is set from environment variable - """ - assert self.instance._get_api_key() == "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p" - - def test_api_key_arg_override_env_var(self, mock_openaq_api_key_env_vars): - """ - tests that api_key argument overrides api key value set in system environment variable - """ - assert self.instance.api_key == "abc123-def456-ghi789" - - def test_api_key_arg_override_env_var(self, mock_config_file): - """ - tests that api_key argument overrides api key value set in openaq config - """ - assert self.instance.api_key == "abc123-def456-ghi789" - - def test_api_key_arg_override_env_vars_config( - self, mock_openaq_api_key_env_vars, mock_config_file - ): - """ - tests that api_key argument overrides api key value set in config file and system environment variable - """ - assert self.instance.api_key == "abc123-def456-ghi789" - - def test_api_key_property(self): - assert self.instance.api_key == 'abc123-def456-ghi789' - with pytest.raises(AttributeError): - self.instance.api_key = 'foobarbaz' - - def test_base_url_property(self): - assert self.instance.base_url == "https://api.openaq.org/v3/" - with pytest.raises(AttributeError): - self.instance.base_url = "https://example.com" - - def test_transport_property(self): - assert self.instance.transport == MockTransport - with pytest.raises(AttributeError): - self.instance.transport = 'foobarbaz' - - def test_headers_property(self): - assert self.instance.headers == { - 'this': 'that', - 'Accept': 'application/json', - 'X-API-Key': 'abc123-def456-ghi789', - 'User-Agent': DEFAULT_USER_AGENT, - } - with pytest.raises(AttributeError): - self.instance.headers = {'openaq': 'api'} - - def test_build_request_headers(self): - request_headers = self.instance.build_request_headers( - {'Accept-Language': 'en-US,en;q=0.5'} - ) - assert request_headers == { - 'this': 'that', - 'Accept': 'application/json', - 'Accept-Language': 'en-US,en;q=0.5', - 'X-API-Key': 'abc123-def456-ghi789', - 'User-Agent': DEFAULT_USER_AGENT, - } - - def test_build_request_headers_none(self): - request_headers = self.instance.build_request_headers() - assert request_headers == { - 'this': 'that', - 'Accept': 'application/json', - 'X-API-Key': 'abc123-def456-ghi789', - 'User-Agent': DEFAULT_USER_AGENT, - } - - def test_auto_wait_default_is_true(self): - """Test that auto_wait defaults to True.""" - instance = SharedClient( - MockTransport, api_key='openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p' - ) - assert instance._auto_wait == True diff --git a/tests/unit/test_shared_models.py b/tests/unit/test_shared_models.py index 238aa048..0d379308 100644 --- a/tests/unit/test_shared_models.py +++ b/tests/unit/test_shared_models.py @@ -2,7 +2,7 @@ import pytest -from openaq.shared.models import build_measurements_path, build_query_params +from openaq.core.models import build_measurements_path, build_query_params @pytest.mark.parametrize( diff --git a/tests/unit/test_shared_responses.py b/tests/unit/test_shared_responses.py index f537780b..ab0b0ee7 100644 --- a/tests/unit/test_shared_responses.py +++ b/tests/unit/test_shared_responses.py @@ -1,3 +1,4 @@ +import http import json import numbers import types @@ -5,10 +6,9 @@ from pathlib import Path from typing import Union, get_args, get_origin, get_type_hints -import httpx import pytest -from openaq.shared.responses import ( +from openaq.core.responses import ( CountriesResponse, Country, Instrument, @@ -28,9 +28,10 @@ ProvidersResponse, Sensor, SensorsResponse, - _ResourceBase, + _ModelBase, _ResponseBase, ) +from openaq.core.transport import Response RATE_LIMIT_HEADERS = { "X-Ratelimit-Limit": "23", @@ -68,10 +69,11 @@ def read_response_file(name: str) -> str: return f.read() -def mock_response(data: str) -> httpx.Response: - return httpx.Response( - status_code=200, headers=RATE_LIMIT_HEADERS, json=json.loads(data) - ) +def mock_response(data: str) -> Response: + msg = http.client.HTTPMessage() + for key, value in RATE_LIMIT_HEADERS.items(): + msg[key] = value + return Response(200, data.encode(), msg) def remove_nulls(value): @@ -120,7 +122,6 @@ def value_matches_type(value, expected_type) -> bool: return isinstance(value, expected_type) -@pytest.mark.respx(base_url="https://api.openaq.org/v3/") def test_rate_limit_headers_response(): """Tests that example JSON responses validate against response models.""" response = read_response_file('locations') @@ -143,8 +144,7 @@ def test_rate_limit_headers_response(): ('sensor', Sensor), ], ) -@pytest.mark.respx(base_url="https://api.openaq.org/v3/") -def test_resources_validation(name: str, resource_class: _ResourceBase): +def test_resources_validation(name: str, resource_class: _ModelBase): """Tests that example JSON responses validate against response models.""" resource = read_resource_file(name) resource_dict = json.loads(resource) @@ -172,7 +172,6 @@ def test_resources_validation(name: str, resource_class: _ResourceBase): ('sensors', SensorsResponse), ], ) -@pytest.mark.respx(base_url="https://api.openaq.org/v3/") def test_responses_validation(name: str, response_class: _ResponseBase): """Tests that example JSON responses validate against response models.""" response = read_response_file(name) @@ -200,7 +199,6 @@ def test_responses_validation(name: str, response_class: _ResponseBase): ('sensors', SensorsResponse), ], ) -@pytest.mark.respx(base_url="https://api.openaq.org/v3/") def test_responses_json(name: str, response_class: _ResponseBase): """Tests that example JSON responses validate against response models.""" response = read_response_file(name) @@ -243,7 +241,6 @@ def test_response_ignores_unexpected_fields(): ('sensors', SensorsResponse), ], ) -@pytest.mark.respx(base_url="https://api.openaq.org/v3/") def test_field_types(name: str, response_class: _ResponseBase): """Tests whether all fields have the correct types""" response = read_response_file(name) diff --git a/tests/unit/test_transport.py b/tests/unit/test_transport.py new file mode 100644 index 00000000..3a29dee3 --- /dev/null +++ b/tests/unit/test_transport.py @@ -0,0 +1,292 @@ +import http.client +import threading +import time +from unittest import mock + +import pytest + +from openaq.core.transport import ( + ConnectionPool, + Headers, + Limits, + PooledConnection, + Response, + Timeout, + TimeoutError, + Transport, + _encode_params, +) + + +def make_response( + status_code: int, body: bytes = b"", headers: dict | None = None +) -> Response: + msg = http.client.HTTPMessage() + if headers: + for key, value in headers.items(): + msg[key] = value + return Response(status_code, body, msg) + + +def make_raw_response( + status: int = 200, body: bytes = b"{}", headers: dict | None = None +): + msg = http.client.HTTPMessage() + if headers: + for k, v in headers.items(): + msg[k] = v + raw = mock.Mock() + raw.status = status + raw.read.return_value = body + raw.msg = msg + return raw + + +def test_explicit_connect_overrides_timeout(): + t = Timeout(10.0, connect=3.0) + assert t.connect == 3.0 + + +class TestHeaders: + def test_keys_are_lowercased_on_set(self): + h = Headers() + h["Content-Type"] = "application/json" + assert "content-type" in h + + def test_get_is_case_insensitive(self): + h = Headers({"X-API-Key": "abc"}) + assert h["x-api-key"] == "abc" + assert h["X-API-Key"] == "abc" + + def test_contains_is_case_insensitive(self): + h = Headers({"Accept": "application/json"}) + assert "accept" in h + assert "Accept" in h + assert "ACCEPT" in h + + def test_get_with_default(self): + h = Headers() + assert h.get("missing") is None + assert h.get("missing", "default") == "default" + + def test_update_lowercases_keys(self): + h = Headers() + h.update({"X-Custom": "value"}) + assert "x-custom" in h + + def test_copy_is_independent(self): + h = Headers({"key": "value"}) + h2 = h.copy() + h2["key"] = "changed" + assert h["key"] == "value" + + +class TestEncodeParams: + def test_none_returns_empty(self): + assert _encode_params(None) == "" + + def test_empty_dict_returns_empty(self): + assert _encode_params({}) == "" + + def test_string_value(self): + assert _encode_params({"key": "value"}) == "key=value" + + def test_int_value(self): + assert _encode_params({"limit": 100}) == "limit=100" + + def test_float_value(self): + assert _encode_params({"lat": 1.5}) == "lat=1.5" + + def test_bool_true_lowercased(self): + assert _encode_params({"active": True}) == "active=true" + + def test_bool_false_lowercased(self): + assert _encode_params({"active": False}) == "active=false" + + def test_multiple_params(self): + result = _encode_params({"limit": 10, "page": 2}) + assert "limit=10" in result + assert "page=2" in result + + +class TestResponse: + def test_text_decodes_utf8(self): + r = make_response(200, "héllo".encode("utf-8")) + assert r.text == "héllo" + + def test_text_uses_charset_from_headers(self): + msg = http.client.HTTPMessage() + msg["Content-Type"] = "text/plain; charset=latin-1" + r = Response(200, "café".encode("latin-1"), msg) + assert r.text == "café" + + def test_text_falls_back_on_bad_charset(self): + msg = http.client.HTTPMessage() + msg["Content-Type"] = "text/plain; charset=nonexistent" + r = Response(200, b"hello", msg) + assert r.text == "hello" + + def test_json_parses_body(self): + r = make_response(200, b'{"key": "value"}') + assert r.json() == {"key": "value"} + + +class TestConnectionPool: + def make_pool(self, max_connections=5, max_keepalive=3, expiry=30.0): + limits = Limits( + max_connections=max_connections, + max_keepalive_connections=max_keepalive, + keepalive_expiry=expiry, + ) + return ConnectionPool(limits, connect_timeout=None) + + def test_acquire_creates_new_connection(self): + pool = self.make_pool() + pc = pool.acquire("api.openaq.org") + assert isinstance(pc, PooledConnection) + assert pc.host == "api.openaq.org" + + def test_acquire_reuses_idle_connection(self): + pool = self.make_pool() + pc = pool.acquire("api.openaq.org") + pool.release(pc) + pc2 = pool.acquire("api.openaq.org") + assert pc is pc2 + + def test_release_discard_closes_and_decrements(self): + pool = self.make_pool() + pc = pool.acquire("api.openaq.org") + pc.conn.close = mock.Mock() + pool.release(pc, discard=True) + assert pool._total == 0 + pc.conn.close.assert_called_once() + + def test_idle_capped_at_max_keepalive(self): + pool = self.make_pool(max_connections=10, max_keepalive=2) + conns = [pool.acquire("api.openaq.org") for _ in range(3)] + for pc in conns: + pc.conn.close = mock.Mock() + pool.release(pc) + assert len(pool._idle["api.openaq.org"]) == 2 + + def test_evicts_expired_connections(self): + pool = self.make_pool(expiry=0.01) + pc = pool.acquire("api.openaq.org") + pool.release(pc) + time.sleep(0.02) + pool.acquire("api.openaq.org") + assert pool._total == 1 + + def test_blocks_when_pool_full(self): + pool = self.make_pool(max_connections=1) + pc = pool.acquire("api.openaq.org") + + acquired = threading.Event() + result = [] + + def try_acquire(): + acquired.set() + result.append(pool.acquire("api.openaq.org", pool_timeout=1.0)) + + t = threading.Thread(target=try_acquire) + t.start() + acquired.wait() + time.sleep(0.05) + pool.release(pc) + t.join(timeout=2.0) + assert len(result) == 1 + + def test_raises_on_pool_timeout(self): + pool = self.make_pool(max_connections=1) + pool.acquire("api.openaq.org") + with pytest.raises(TimeoutError, match="Connection pool exhausted"): + pool.acquire("api.openaq.org", pool_timeout=0.05) + + def test_close_all_clears_pool(self): + pool = self.make_pool() + pc = pool.acquire("api.openaq.org") + pool.release(pc) + pool.close_all() + assert pool._idle == {} + assert pool._total == 0 + + +class TestTransport: + def make_transport(self, **kwargs) -> Transport: + return Transport(**kwargs) + + def patch_pool(self, transport, raw_response): + pc = mock.Mock() + pc.conn.sock = None + pc.host = "api.openaq.org" + pc.conn.getresponse.return_value = raw_response + acquire = mock.patch.object(transport._pool, 'acquire', return_value=pc) + release = mock.patch.object(transport._pool, 'release') + return acquire, release, pc + + def test_float_timeout_sets_both(self): + t = Transport(timeout=5.0) + assert t._connect_timeout == 5.0 + assert t._read_timeout == 5.0 + + def test_send_request_encodes_params_in_path(self): + transport = self.make_transport() + raw = make_raw_response() + acquire, release, pc = self.patch_pool(transport, raw) + + with acquire, release: + transport.send_request( + "GET", "https://api.openaq.org/v3/locations", {"limit": 10}, Headers() + ) + call_path = pc.conn.request.call_args[0][1] + assert "limit=10" in call_path + + def test_send_request_appends_params_with_ampersand_when_query_exists(self): + transport = self.make_transport() + raw = make_raw_response() + acquire, release, pc = self.patch_pool(transport, raw) + + with acquire, release: + transport.send_request( + "GET", + "https://api.openaq.org/v3/locations?page=1", + {"limit": 10}, + Headers(), + ) + call_path = pc.conn.request.call_args[0][1] + assert "page=1" in call_path + assert "limit=10" in call_path + assert "&limit=10" in call_path + + def test_send_request_no_params_leaves_url_unchanged(self): + transport = self.make_transport() + raw = make_raw_response() + acquire, release, pc = self.patch_pool(transport, raw) + + with acquire, release: + transport.send_request( + "GET", "https://api.openaq.org/v3/locations", None, Headers() + ) + call_path = pc.conn.request.call_args[0][1] + assert call_path == "/v3/locations" + + def test_retries_on_stale_connection(self): + transport = self.make_transport() + raw = make_raw_response() + acquire, release, pc = self.patch_pool(transport, raw) + pc.conn.getresponse.side_effect = [OSError("stale"), raw] + + with acquire, release: + resp = transport._raw_request("GET", "api.openaq.org", "/v3/locations", {}) + assert resp.status_code == 200 + assert pc.conn.getresponse.call_count == 2 + + def test_raises_after_two_failures(self): + transport = self.make_transport() + raw = make_raw_response() + acquire, release, pc = self.patch_pool(transport, raw) + pc.conn.getresponse.side_effect = OSError("stale") + + with acquire, release: + with pytest.raises(OSError): + transport._raw_request("GET", "api.openaq.org", "/v3/locations", {}) diff --git a/tests/unit/test_validators.py b/tests/unit/test_validators.py index cc5b272b..e69787ac 100644 --- a/tests/unit/test_validators.py +++ b/tests/unit/test_validators.py @@ -3,12 +3,12 @@ import pytest from freezegun import freeze_time -from openaq.shared.exceptions import ( +from openaq.core.exceptions import ( IdentifierOutOfBoundsError, InvalidParameterError, ) -from openaq.shared.types import Data -from openaq.shared.validators import ( +from openaq.core.types import Data +from openaq.core.validators import ( countries_id_iso_exclusivity_check, data_check, date_from_lesser_check,