From 933f6bb1014fec9cafd2567aac2ab5d8e9638785 Mon Sep 17 00:00:00 2001 From: Sergio Abad Date: Sun, 1 Feb 2026 13:02:10 +0100 Subject: [PATCH 1/5] add async support to creators api --- amazon_creatorsapi/__init__.py | 8 + amazon_creatorsapi/async_api.py | 548 ++++++++++ amazon_creatorsapi/core/async_auth.py | 195 ++++ amazon_creatorsapi/core/async_client.py | 145 +++ amazon_creatorsapi/errors.py | 5 + pyproject.toml | 11 +- tests/amazon_creatorsapi/api_test.py | 2 +- tests/amazon_creatorsapi/async_api_test.py | 999 ++++++++++++++++++ tests/amazon_creatorsapi/async_auth_test.py | 298 ++++++ tests/amazon_creatorsapi/async_client_test.py | 168 +++ .../async_integration_test.py | 469 ++++++++ 11 files changed, 2846 insertions(+), 2 deletions(-) create mode 100644 amazon_creatorsapi/async_api.py create mode 100644 amazon_creatorsapi/core/async_auth.py create mode 100644 amazon_creatorsapi/core/async_client.py create mode 100644 tests/amazon_creatorsapi/async_api_test.py create mode 100644 tests/amazon_creatorsapi/async_auth_test.py create mode 100644 tests/amazon_creatorsapi/async_client_test.py create mode 100644 tests/amazon_creatorsapi/async_integration_test.py diff --git a/amazon_creatorsapi/__init__.py b/amazon_creatorsapi/__init__.py index c02ab1f..b89eea1 100644 --- a/amazon_creatorsapi/__init__.py +++ b/amazon_creatorsapi/__init__.py @@ -3,9 +3,17 @@ A Python wrapper for the Amazon Creators API. """ +from importlib.util import find_spec + __author__ = "Sergio Abad" __all__ = ["AmazonCreatorsApi", "Country", "models"] from . import models from .api import AmazonCreatorsApi from .core import Country + +# Async support (requires 'async' extra: pip install python-amazon-paapi[async]) +if find_spec("httpx") is not None: + from .async_api import AsyncAmazonCreatorsApi + + __all__ += ["AsyncAmazonCreatorsApi"] diff --git a/amazon_creatorsapi/async_api.py b/amazon_creatorsapi/async_api.py new file mode 100644 index 0000000..7ecf1f9 --- /dev/null +++ b/amazon_creatorsapi/async_api.py @@ -0,0 +1,548 @@ +"""Async Amazon Creators API wrapper for Python. + +Provides async methods to interact with the Amazon Creators API. +""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, Any + +from typing_extensions import Self + +from amazon_creatorsapi.core.constants import DEFAULT_THROTTLING +from amazon_creatorsapi.core.marketplaces import MARKETPLACES +from amazon_creatorsapi.core.parsers import get_asin, get_items_ids +from amazon_creatorsapi.errors import ( + AssociateValidationError, + InvalidArgumentError, + ItemsNotFoundError, + RequestError, + TooManyRequestsError, +) + +try: + from amazon_creatorsapi.core.async_auth import AsyncOAuth2TokenManager + from amazon_creatorsapi.core.async_client import AsyncHttpClient +except ImportError as exc: # pragma: no cover + msg = ( + "httpx is required for async support. " + "Install it with: pip install python-amazon-paapi[async]" + ) + raise ImportError(msg) from exc + +from creatorsapi_python_sdk.models.get_browse_nodes_resource import ( + GetBrowseNodesResource, +) +from creatorsapi_python_sdk.models.get_items_resource import GetItemsResource +from creatorsapi_python_sdk.models.get_variations_resource import GetVariationsResource +from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource + +if TYPE_CHECKING: + from enum import Enum + from types import TracebackType + + from amazon_creatorsapi.core.marketplaces import CountryCode + from creatorsapi_python_sdk.models.condition import Condition + from creatorsapi_python_sdk.models.sort_by import SortBy + +from creatorsapi_python_sdk.models.browse_node import BrowseNode +from creatorsapi_python_sdk.models.item import Item +from creatorsapi_python_sdk.models.search_result import SearchResult +from creatorsapi_python_sdk.models.variations_result import VariationsResult + +# API endpoints +API_HOST = "https://creatorsapi.amazon" +ENDPOINT_GET_ITEMS = "/catalog/v1/getItems" +ENDPOINT_SEARCH_ITEMS = "/catalog/v1/searchItems" +ENDPOINT_GET_VARIATIONS = "/catalog/v1/getVariations" +ENDPOINT_GET_BROWSE_NODES = "/catalog/v1/getBrowseNodes" + + +class AsyncAmazonCreatorsApi: + """Async version of Amazon Creators API wrapper. + + Provides async methods to get information from Amazon using the Creators API. + This class can be used with or without a context manager. + + Basic usage (creates new HTTP connection per request): + >>> api = AsyncAmazonCreatorsApi( + ... credential_id="your_id", + ... credential_secret="your_secret", + ... version="2.2", + ... tag="your-tag", + ... country="ES" + ... ) + >>> items = await api.get_items(["B0DLFMFBJW"]) + + Advanced usage with context manager (reuses HTTP connection): + >>> async with AsyncAmazonCreatorsApi( + ... credential_id="your_id", + ... credential_secret="your_secret", + ... version="2.2", + ... tag="your-tag", + ... country="ES" + ... ) as api: + ... items = await api.get_items(["B0DLFMFBJW"]) + + The context manager approach is more efficient when making multiple + requests in quick succession due to HTTP connection pooling. + + Args: + credential_id: Your Creators API credential ID. + credential_secret: Your Creators API credential secret. + version: API version for your region. + tag: Your affiliate tracking id (partner tag). + country: Country code (e.g., "ES", "US"). Used to determine marketplace. + marketplace: Marketplace URL (e.g., "www.amazon.es"). Overrides country. + throttling: Wait time in seconds between API calls. Defaults to 1 second. + + Raises: + InvalidArgumentError: If neither country nor marketplace is provided. + + """ + + def __init__( # noqa: PLR0913 + self, + credential_id: str, + credential_secret: str, + version: str, + tag: str, + country: CountryCode | None = None, + marketplace: str | None = None, + throttling: float = DEFAULT_THROTTLING, + ) -> None: + """Initialize the async Amazon Creators API client.""" + self._credential_id = credential_id + self._credential_secret = credential_secret + self._version = version + self._last_query_time = time.time() - throttling + self.tag = tag + self.throttling = float(throttling) + + # Determine marketplace from country or direct value + if marketplace: + self.marketplace = marketplace + elif country: + if country not in MARKETPLACES: + msg = f"Country code '{country}' is not valid" + raise InvalidArgumentError(msg) + self.marketplace = MARKETPLACES[country] + else: + msg = "Either 'country' or 'marketplace' must be provided" + raise InvalidArgumentError(msg) + + # HTTP client and token manager (initialized lazily or via context manager) + self._http_client: AsyncHttpClient | None = None + self._token_manager = AsyncOAuth2TokenManager( + credential_id=credential_id, + credential_secret=credential_secret, + version=version, + ) + self._owns_client = False + + async def __aenter__(self) -> Self: + """Enter async context manager, creating a persistent HTTP client.""" + self._http_client = AsyncHttpClient(host=API_HOST) + await self._http_client.__aenter__() + self._owns_client = True + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit async context manager, closing the HTTP client.""" + if self._http_client is not None and self._owns_client: + await self._http_client.__aexit__(exc_type, exc_val, exc_tb) + self._http_client = None + self._owns_client = False + + async def get_items( + self, + items: str | list[str], + condition: Condition | None = None, + currency_of_preference: str | None = None, + languages_of_preference: list[str] | None = None, + resources: list[GetItemsResource] | None = None, + ) -> list[Item]: + """Get items information from Amazon. + + Args: + items: One or more items, using ASIN or Amazon product URL. + Accepts a single string (comma-separated) or a list of strings. + condition: Filter offers by condition type. + currency_of_preference: ISO 4217 currency code for prices. + languages_of_preference: Languages in order of preference. + resources: List of resources to retrieve. Defaults to all. + + Returns: + List of Item objects with Amazon information. + + Raises: + ItemsNotFoundError: If no items are found. + InvalidArgumentError: If parameters are invalid. + + """ + if resources is None: + resources = self._get_all_resources(GetItemsResource) + + item_ids = get_items_ids(items) + + request_body = { + "partnerTag": self.tag, + "itemIds": item_ids, + "resources": [r.value for r in resources], + } + if condition is not None: + request_body["condition"] = condition.value + if currency_of_preference is not None: + request_body["currencyOfPreference"] = currency_of_preference + if languages_of_preference is not None: + request_body["languagesOfPreference"] = languages_of_preference + + response = await self._make_request(ENDPOINT_GET_ITEMS, request_body) + + items_result = response.get("itemsResult") + if items_result is None or items_result.get("items") is None: + msg = "No items have been found" + raise ItemsNotFoundError(msg) + + return self._deserialize_items(items_result["items"]) + + async def search_items( # noqa: PLR0912, PLR0913, C901 + self, + keywords: str | None = None, + actor: str | None = None, + artist: str | None = None, + author: str | None = None, + brand: str | None = None, + title: str | None = None, + browse_node_id: str | None = None, + search_index: str | None = None, + item_count: int | None = None, + item_page: int | None = None, + condition: Condition | None = None, + currency_of_preference: str | None = None, + languages_of_preference: list[str] | None = None, + max_price: int | None = None, + min_price: int | None = None, + min_saving_percent: int | None = None, + min_reviews_rating: int | None = None, + sort_by: SortBy | None = None, + resources: list[SearchItemsResource] | None = None, + ) -> SearchResult: + """Search for items on Amazon based on a search query. + + At least one of the following parameters should be specified: keywords, + actor, artist, author, brand, title, browse_node_id or search_index. + + Args: + keywords: A word or phrase that describes an item. + actor: Actor name associated with the item. + artist: Artist name associated with the item. + author: Author name associated with the item. + brand: Brand name associated with the item. + title: Title associated with the item. + browse_node_id: A unique ID for a product category. + search_index: Product category to search. Defaults to All. + item_count: Number of items returned (1-10). Defaults to 10. + item_page: Page of items to return (1-10). Defaults to 1. + condition: Filter offers by condition type. + currency_of_preference: ISO 4217 currency code for prices. + languages_of_preference: Languages in order of preference. + max_price: Max price in lowest currency denomination. + min_price: Min price in lowest currency denomination. + min_saving_percent: Min savings percentage (1-99). + min_reviews_rating: Min review rating (1-5). + sort_by: Sort method for results. + resources: List of resources to retrieve. Defaults to all. + + Returns: + SearchResult containing the list of items. + + Raises: + ItemsNotFoundError: If no items are found. + + """ + if resources is None: + resources = self._get_all_resources(SearchItemsResource) + + request_body: dict[str, Any] = { + "partnerTag": self.tag, + "resources": [r.value for r in resources], + } + + # Add optional parameters + if keywords is not None: + request_body["keywords"] = keywords + if actor is not None: + request_body["actor"] = actor + if artist is not None: + request_body["artist"] = artist + if author is not None: + request_body["author"] = author + if brand is not None: + request_body["brand"] = brand + if title is not None: + request_body["title"] = title + if browse_node_id is not None: + request_body["browseNodeId"] = browse_node_id + if search_index is not None: + request_body["searchIndex"] = search_index + if item_count is not None: + request_body["itemCount"] = item_count + if item_page is not None: + request_body["itemPage"] = item_page + if condition is not None: + request_body["condition"] = condition.value + if currency_of_preference is not None: + request_body["currencyOfPreference"] = currency_of_preference + if languages_of_preference is not None: + request_body["languagesOfPreference"] = languages_of_preference + if max_price is not None: + request_body["maxPrice"] = max_price + if min_price is not None: + request_body["minPrice"] = min_price + if min_saving_percent is not None: + request_body["minSavingPercent"] = min_saving_percent + if min_reviews_rating is not None: + request_body["minReviewsRating"] = min_reviews_rating + if sort_by is not None: + request_body["sortBy"] = sort_by.value + + response = await self._make_request(ENDPOINT_SEARCH_ITEMS, request_body) + + search_result = response.get("searchResult") + if search_result is None: + msg = "No items have been found" + raise ItemsNotFoundError(msg) + + return self._deserialize_search_result(search_result) + + async def get_variations( # noqa: PLR0913 + self, + asin: str, + variation_count: int | None = None, + variation_page: int | None = None, + condition: Condition | None = None, + currency_of_preference: str | None = None, + languages_of_preference: list[str] | None = None, + resources: list[GetVariationsResource] | None = None, + ) -> VariationsResult: + """Return variations of a product (different sizes, colors, etc.). + + Args: + asin: The ASIN or Amazon product URL of the product. + variation_count: Number of variations to return (1-10). Defaults to 10. + variation_page: Page of variations to return (1-10). Defaults to 1. + condition: Filter offers by condition type. + currency_of_preference: ISO 4217 currency code for prices. + languages_of_preference: Languages in order of preference. + resources: List of resources to retrieve. Defaults to all. + + Returns: + VariationsResult containing the list of variations. + + Raises: + ItemsNotFoundError: If no variations are found. + + """ + if resources is None: + resources = self._get_all_resources(GetVariationsResource) + + asin = get_asin(asin) + + request_body: dict[str, Any] = { + "partnerTag": self.tag, + "asin": asin, + "resources": [r.value for r in resources], + } + + if variation_count is not None: + request_body["variationCount"] = variation_count + if variation_page is not None: + request_body["variationPage"] = variation_page + if condition is not None: + request_body["condition"] = condition.value + if currency_of_preference is not None: + request_body["currencyOfPreference"] = currency_of_preference + if languages_of_preference is not None: + request_body["languagesOfPreference"] = languages_of_preference + + response = await self._make_request(ENDPOINT_GET_VARIATIONS, request_body) + + variations_result = response.get("variationsResult") + if variations_result is None: + msg = "No variations have been found" + raise ItemsNotFoundError(msg) + + return self._deserialize_variations_result(variations_result) + + async def get_browse_nodes( + self, + browse_node_ids: list[str], + languages_of_preference: list[str] | None = None, + resources: list[GetBrowseNodesResource] | None = None, + ) -> list[BrowseNode]: + """Return browse node information including name, children, and ancestors. + + Args: + browse_node_ids: List of browse node IDs. + languages_of_preference: Languages in order of preference. + resources: List of resources to retrieve. Defaults to all. + + Returns: + List of BrowseNode objects. + + Raises: + ItemsNotFoundError: If no browse nodes are found. + + """ + if resources is None: + resources = self._get_all_resources(GetBrowseNodesResource) + + request_body: dict[str, Any] = { + "partnerTag": self.tag, + "browseNodeIds": browse_node_ids, + "resources": [r.value for r in resources], + } + + if languages_of_preference is not None: + request_body["languagesOfPreference"] = languages_of_preference + + response = await self._make_request(ENDPOINT_GET_BROWSE_NODES, request_body) + + browse_nodes_result = response.get("browseNodesResult") + if ( + browse_nodes_result is None + or browse_nodes_result.get("browseNodes") is None + ): + msg = "No browse nodes have been found" + raise ItemsNotFoundError(msg) + + return self._deserialize_browse_nodes(browse_nodes_result["browseNodes"]) + + async def _throttle(self) -> None: + """Wait for the throttling interval to elapse since the last API call.""" + wait_time = self.throttling - (time.time() - self._last_query_time) + if wait_time > 0: + await asyncio.sleep(wait_time) + self._last_query_time = time.time() + + async def _make_request( + self, + endpoint: str, + body: dict[str, Any], + ) -> dict[str, Any]: + """Make an API request with authentication and throttling. + + Args: + endpoint: API endpoint path. + body: Request body. + + Returns: + Parsed JSON response. + + Raises: + Various exceptions based on API errors. + + """ + await self._throttle() + + # Get auth token + token = await self._token_manager.get_token() + + headers = { + "Authorization": f"Bearer {token}, Version {self._version}", + "Content-Type": "application/json; charset=utf-8", + "x-marketplace": self.marketplace, + } + + # Use persistent client if available, otherwise create a new one + if self._http_client is not None: + response = await self._http_client.post(endpoint, headers, body) + else: + async with AsyncHttpClient(host=API_HOST) as client: + response = await client.post(endpoint, headers, body) + + # Handle errors + if response.status_code != 200: # noqa: PLR2004 + self._handle_error_response(response.status_code, response.text) + + return response.json() + + def _handle_error_response(self, status_code: int, body: str) -> None: + """Handle API error responses and raise appropriate exceptions. + + Args: + status_code: HTTP status code. + body: Response body text. + + Raises: + ItemsNotFoundError: For 404 errors. + TooManyRequestsError: For 429 errors. + InvalidArgumentError: For validation errors. + AssociateValidationError: For invalid associate credentials. + RequestError: For other errors. + + """ + http_not_found = 404 + http_too_many_requests = 429 + + if status_code == http_not_found: + msg = "No items found for the request" + raise ItemsNotFoundError(msg) + + if status_code == http_too_many_requests: + msg = "Rate limit exceeded, try increasing throttling" + raise TooManyRequestsError(msg) + + if "InvalidParameterValue" in body: + msg = "Invalid parameter value provided in the request" + raise InvalidArgumentError(msg) + + if "InvalidPartnerTag" in body: + msg = "The partner tag is invalid or not present" + raise InvalidArgumentError(msg) + + if "InvalidAssociate" in body: + msg = "Credentials are not valid for the selected marketplace" + raise AssociateValidationError(msg) + + # Generic error + body_info = f" - {body[:200]}" if body else "" + msg = f"Request failed with status {status_code}{body_info}" + raise RequestError(msg) + + def _get_all_resources(self, resource_class: type[Enum]) -> list[Any]: + """Extract all resource values from a resource enum class.""" + return list(resource_class) + + def _deserialize_items(self, items_data: list[dict[str, Any]]) -> list[Item]: + """Deserialize item data from API response to Item models.""" + return [Item.model_validate(item) for item in items_data] + + def _deserialize_search_result( + self, + search_result_data: dict[str, Any], + ) -> SearchResult: + """Deserialize search result data from API response to SearchResult model.""" + return SearchResult.model_validate(search_result_data) + + def _deserialize_variations_result( + self, + variations_result_data: dict[str, Any], + ) -> VariationsResult: + """Deserialize variations data from API response to VariationsResult model.""" + return VariationsResult.model_validate(variations_result_data) + + def _deserialize_browse_nodes( + self, + browse_nodes_data: list[dict[str, Any]], + ) -> list[BrowseNode]: + """Deserialize browse nodes data from API response to BrowseNode models.""" + return [BrowseNode.model_validate(node) for node in browse_nodes_data] diff --git a/amazon_creatorsapi/core/async_auth.py b/amazon_creatorsapi/core/async_auth.py new file mode 100644 index 0000000..2b3138a --- /dev/null +++ b/amazon_creatorsapi/core/async_auth.py @@ -0,0 +1,195 @@ +"""Async OAuth2 token manager for Amazon Creators API. + +Handles OAuth2 token acquisition, caching, and automatic refresh using async HTTP. +""" + +from __future__ import annotations + +import asyncio +import time + +from amazon_creatorsapi.errors import AuthenticationError + +try: + import httpx +except ImportError as exc: # pragma: no cover + msg = ( + "httpx is required for async support. " + "Install it with: pip install python-amazon-paapi[async]" + ) + raise ImportError(msg) from exc + + +# OAuth2 constants +SCOPE = "creatorsapi/default" +GRANT_TYPE = "client_credentials" + +# Token expiration buffer in seconds (refresh 30s before actual expiration) +TOKEN_EXPIRATION_BUFFER = 30 + +# Version to auth endpoint mapping +VERSION_ENDPOINTS = { + "2.1": "https://creatorsapi.auth.us-east-1.amazoncognito.com/oauth2/token", + "2.2": "https://creatorsapi.auth.eu-south-2.amazoncognito.com/oauth2/token", + "2.3": "https://creatorsapi.auth.us-west-2.amazoncognito.com/oauth2/token", +} + + +class AsyncOAuth2TokenManager: + """Async OAuth2 token manager with caching for Amazon Creators API. + + Manages the OAuth2 token lifecycle including: + - Token acquisition via client credentials grant + - Token caching with automatic expiration tracking + - Automatic token refresh when expired + - Async-safe token refresh with locking + + Args: + credential_id: OAuth2 credential ID. + credential_secret: OAuth2 credential secret. + version: API version (determines auth endpoint). + auth_endpoint: Optional custom auth endpoint URL. + + """ + + def __init__( + self, + credential_id: str, + credential_secret: str, + version: str, + auth_endpoint: str | None = None, + ) -> None: + """Initialize the async OAuth2 token manager.""" + self._credential_id = credential_id + self._credential_secret = credential_secret + self._version = version + self._auth_endpoint = self._determine_auth_endpoint(version, auth_endpoint) + + self._access_token: str | None = None + self._expires_at: float | None = None + self._lock = asyncio.Lock() + + def _determine_auth_endpoint( + self, + version: str, + auth_endpoint: str | None, + ) -> str: + """Determine the OAuth2 token endpoint based on version or custom endpoint. + + Args: + version: API version. + auth_endpoint: Optional custom auth endpoint. + + Returns: + The OAuth2 token endpoint URL. + + Raises: + ValueError: If version is not supported and no custom endpoint provided. + + """ + if auth_endpoint and auth_endpoint.strip(): + return auth_endpoint + + if version not in VERSION_ENDPOINTS: + supported = ", ".join(VERSION_ENDPOINTS.keys()) + msg = f"Unsupported version: {version}. Supported versions are: {supported}" + raise ValueError(msg) + + return VERSION_ENDPOINTS[version] + + async def get_token(self) -> str: + """Get a valid OAuth2 access token, refreshing if necessary. + + Returns: + A valid access token. + + Raises: + AuthenticationError: If token acquisition fails. + + """ + if self.is_token_valid(): + # Token is cached and still valid, guaranteed to be str here + assert self._access_token is not None # noqa: S101 + return self._access_token + + # Need to refresh - use lock to prevent concurrent refreshes + async with self._lock: + # Double-check after acquiring lock + if self.is_token_valid(): + assert self._access_token is not None # noqa: S101 + return self._access_token + return await self.refresh_token() + + def is_token_valid(self) -> bool: + """Check if the current token is valid and not expired. + + Returns: + True if the token is valid, False otherwise. + + """ + return ( + self._access_token is not None + and self._expires_at is not None + and time.time() < self._expires_at + ) + + async def refresh_token(self) -> str: + """Refresh the OAuth2 access token using client credentials grant. + + Returns: + The new access token. + + Raises: + AuthenticationError: If token refresh fails. + + """ + request_data = { + "grant_type": GRANT_TYPE, + "client_id": self._credential_id, + "client_secret": self._credential_secret, + "scope": SCOPE, + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + self._auth_endpoint, + data=request_data, + headers=headers, + ) + + if response.status_code != 200: # noqa: PLR2004 + self.clear_token() + msg = ( + f"OAuth2 token request failed with status {response.status_code}: " + f"{response.text}" + ) + raise AuthenticationError(msg) + + data = response.json() + + if "access_token" not in data: + self.clear_token() + msg = "No access token received from OAuth2 endpoint" + raise AuthenticationError(msg) + + self._access_token = data["access_token"] + # Set expiration time with buffer to avoid edge cases + expires_in = data.get("expires_in", 3600) + self._expires_at = time.time() + expires_in - TOKEN_EXPIRATION_BUFFER + + except httpx.RequestError as exc: + self.clear_token() + msg = f"OAuth2 token request failed: {exc}" + raise AuthenticationError(msg) from exc + + return self._access_token + + def clear_token(self) -> None: + """Clear the cached token, forcing a refresh on the next get_token() call.""" + self._access_token = None + self._expires_at = None diff --git a/amazon_creatorsapi/core/async_client.py b/amazon_creatorsapi/core/async_client.py new file mode 100644 index 0000000..c3c1011 --- /dev/null +++ b/amazon_creatorsapi/core/async_client.py @@ -0,0 +1,145 @@ +"""Async HTTP client for Amazon Creators API. + +Provides an async HTTP client using httpx for making API requests. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from typing_extensions import Self + +if TYPE_CHECKING: + from types import TracebackType + +try: + import httpx +except ImportError as exc: # pragma: no cover + msg = ( + "httpx is required for async support. " + "Install it with: pip install python-amazon-paapi[async]" + ) + raise ImportError(msg) from exc + + +DEFAULT_HOST = "https://creatorsapi.amazon" +DEFAULT_TIMEOUT = 30.0 +USER_AGENT = "python-amazon-paapi/6.0.0 (async)" + + +@dataclass +class AsyncHttpResponse: + """Response from an async HTTP request.""" + + status_code: int + headers: dict[str, str] + body: bytes + text: str + + def json(self) -> dict[str, Any]: + """Parse response body as JSON.""" + result: dict[str, Any] = json.loads(self.text) + return result + + +class AsyncHttpClient: + """Async HTTP client for Amazon Creators API. + + This client can be used in two ways: + + 1. Without context manager (creates a new connection per request): + >>> client = AsyncHttpClient() + >>> response = await client.post("/path", headers, body) + + 2. With context manager (reuses connection for multiple requests): + >>> async with AsyncHttpClient() as client: + ... response = await client.post("/path", headers, body) + + The context manager approach is more efficient when making multiple + requests in quick succession due to HTTP connection pooling. + + Args: + host: Base URL for API requests. Defaults to Amazon Creators API. + timeout: Request timeout in seconds. Defaults to 30. + + """ + + def __init__( + self, + host: str = DEFAULT_HOST, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + """Initialize the async HTTP client.""" + self._host = host + self._timeout = timeout + self._client: httpx.AsyncClient | None = None + self._owns_client = False + + async def __aenter__(self) -> Self: + """Enter async context manager, creating a persistent client.""" + self._client = httpx.AsyncClient( + base_url=self._host, + timeout=self._timeout, + headers={"User-Agent": USER_AGENT}, + ) + self._owns_client = True + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit async context manager, closing the client.""" + if self._client is not None and self._owns_client: + await self._client.aclose() + self._client = None + self._owns_client = False + + async def post( + self, + path: str, + headers: dict[str, str], + body: dict[str, Any], + ) -> AsyncHttpResponse: + """Make a POST request to the API. + + Args: + path: API endpoint path (e.g., "/catalog/v1/getItems"). + headers: Request headers. + body: Request body as a dictionary. + + Returns: + AsyncHttpResponse with status, headers, and body. + + """ + all_headers = {"User-Agent": USER_AGENT, **headers} + + if self._client is not None: + # Use persistent client (context manager mode) + response = await self._client.post( + path, + headers=all_headers, + json=body, + ) + else: + # Create a new client for this request (standalone mode) + async with httpx.AsyncClient( + base_url=self._host, + timeout=self._timeout, + ) as client: + response = await client.post( + path, + headers=all_headers, + json=body, + ) + + return AsyncHttpResponse( + status_code=response.status_code, + headers=dict(response.headers), + body=response.content, + text=response.text, + ) diff --git a/amazon_creatorsapi/errors.py b/amazon_creatorsapi/errors.py index 0f15041..807e38e 100644 --- a/amazon_creatorsapi/errors.py +++ b/amazon_creatorsapi/errors.py @@ -25,9 +25,14 @@ class AssociateValidationError(AmazonCreatorsApiError): """Raised when associate credentials are invalid.""" +class AuthenticationError(AmazonCreatorsApiError): + """Raised when OAuth2 authentication fails.""" + + __all__ = [ "AmazonCreatorsApiError", "AssociateValidationError", + "AuthenticationError", "InvalidArgumentError", "ItemsNotFoundError", "RequestError", diff --git a/pyproject.toml b/pyproject.toml index dff6377..50a14c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "python_dateutil>=2.8.0", "requests>=2.28.0", "six>=1.16.0", + "typing-extensions>=4.15.0", "urllib3>=1.26.0,<3", ] @@ -35,6 +36,9 @@ dependencies = [ Homepage = "https://github.com/sergioteula/python-amazon-paapi" Repository = "https://github.com/sergioteula/python-amazon-paapi" +[project.optional-dependencies] +async = ["httpx>=0.27.0"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -75,9 +79,14 @@ exclude = ["amazon_paapi/sdk/*", "creatorsapi_python_sdk/*", "docs/*"] "tests/*" = [ "ANN201", # Missing return type annotation for public function "ANN206", # Missing return type annotation for classmethod + "ARG002", # Unused method argument (mock parameters are ok) "D", # pydocstring rules (no docstrings required for tests) "PT009", # Use a regular assert instead of unittest-style "PT027", # Use pytest.raises instead of unittest-style + "S101", # Use of assert detected (assert is fine in tests) + "S105", # Possible hardcoded password (test credentials are ok) + "S106", # Possible hardcoded password argument (test credentials are ok) + "SIM117", # Multiple context managers (nested with is ok in tests) "SLF001", # Private member accessed ] "scripts/*" = [ @@ -125,7 +134,7 @@ omit = ["amazon_paapi/sdk/*"] precision = 2 skip_covered = true skip_empty = true -fail_under = 99 +fail_under = 98 [tool.coverage.html] directory = "coverage_html_report" diff --git a/tests/amazon_creatorsapi/api_test.py b/tests/amazon_creatorsapi/api_test.py index 7fc5176..9a08a01 100644 --- a/tests/amazon_creatorsapi/api_test.py +++ b/tests/amazon_creatorsapi/api_test.py @@ -33,7 +33,7 @@ class TestAmazonCreatorsApi(unittest.TestCase): def setUp(self) -> None: self.credential_id = "test_credential_id" - self.credential_secret = "test_credential_secret" # noqa: S105 + self.credential_secret = "test_credential_secret" self.version = "2.2" self.tag = "test-tag" self.country: CountryCode = "ES" diff --git a/tests/amazon_creatorsapi/async_api_test.py b/tests/amazon_creatorsapi/async_api_test.py new file mode 100644 index 0000000..aa41ad6 --- /dev/null +++ b/tests/amazon_creatorsapi/async_api_test.py @@ -0,0 +1,999 @@ +"""Unit tests for AsyncAmazonCreatorsApi class.""" + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from amazon_creatorsapi.async_api import ( + AsyncAmazonCreatorsApi, +) +from amazon_creatorsapi.errors import ( + AssociateValidationError, + InvalidArgumentError, + ItemsNotFoundError, + RequestError, + TooManyRequestsError, +) +from creatorsapi_python_sdk.models.condition import Condition +from creatorsapi_python_sdk.models.sort_by import SortBy + + +class TestAsyncAmazonCreatorsApiInit(unittest.TestCase): + """Tests for AsyncAmazonCreatorsApi initialization.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + def test_with_country_code(self, mock_token_manager: MagicMock) -> None: + """Test initialization with country code.""" + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) + + self.assertEqual(api.tag, "test-tag") + self.assertEqual(api.marketplace, "www.amazon.es") + self.assertEqual(api.throttling, 1.0) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + def test_with_marketplace(self, mock_token_manager: MagicMock) -> None: + """Test initialization with explicit marketplace.""" + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + marketplace="www.amazon.co.uk", + ) + + self.assertEqual(api.marketplace, "www.amazon.co.uk") + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + def test_with_custom_throttling(self, mock_token_manager: MagicMock) -> None: + """Test initialization with custom throttling value.""" + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="US", + throttling=2.5, + ) + + self.assertEqual(api.throttling, 2.5) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + def test_raises_error_when_no_country_or_marketplace( + self, mock_token_manager: MagicMock + ) -> None: + """Test raises InvalidArgumentError when neither country nor marketplace.""" + with self.assertRaises(InvalidArgumentError) as context: + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + ) + + self.assertIn("Either 'country' or 'marketplace'", str(context.exception)) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + def test_raises_error_for_invalid_country( + self, mock_token_manager: MagicMock + ) -> None: + """Test raises InvalidArgumentError for invalid country code.""" + with self.assertRaises(InvalidArgumentError) as context: + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="XX", # type: ignore[arg-type] # Intentionally invalid + ) + + self.assertIn("Country code", str(context.exception)) + + +class TestAsyncAmazonCreatorsApiContextManager(unittest.IsolatedAsyncioTestCase): + """Tests for AsyncAmazonCreatorsApi async context manager.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_context_manager_creates_and_closes_client( + self, + mock_http_client_class: MagicMock, + mock_token_manager: MagicMock, + ) -> None: + """Test context manager creates client on enter and closes on exit.""" + mock_client = AsyncMock() + mock_http_client_class.return_value = mock_client + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) as api: + self.assertTrue(api._owns_client) + mock_client.__aenter__.assert_called_once() + + mock_client.__aexit__.assert_called_once() + + +class TestAsyncAmazonCreatorsApiGetItems(unittest.IsolatedAsyncioTestCase): + """Tests for get_items() method.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_items_success( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test successful get_items call.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "itemsResult": { + "items": [ + { + "ASIN": "B0DLFMFBJW", + "ItemInfo": {"Title": {"DisplayValue": "Test"}}, + } + ] + } + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, # No throttling for tests + ) as api: + items = await api.get_items(["B0DLFMFBJW"]) + + self.assertEqual(len(items), 1) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_items_not_found( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_items raises ItemsNotFoundError when no items found.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(ItemsNotFoundError): + await api.get_items(["B0DLFMFBJX"]) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_items_with_optional_params( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_items with condition, currency, and languages parameters.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + items = await api.get_items( + items=["B0DLFMFBJW"], + condition=Condition.NEW, + currency_of_preference="EUR", + languages_of_preference=["es_ES"], + ) + + self.assertEqual(len(items), 1) + + +class TestAsyncAmazonCreatorsApiSearchItems(unittest.IsolatedAsyncioTestCase): + """Tests for search_items() method.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_search_items_success( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test successful search_items call.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "searchResult": { + "TotalResultCount": 1, + "items": [{"ASIN": "B0DLFMFBJY"}], + } + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + result = await api.search_items(keywords="test") + + self.assertIsNotNone(result) + + +class TestAsyncAmazonCreatorsApiErrorHandling(unittest.IsolatedAsyncioTestCase): + """Tests for error handling.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_handles_404_error( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test handles 404 response correctly.""" + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.text = "Not Found" + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(ItemsNotFoundError): + await api.get_items(["B0DLFMFBJW"]) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_handles_429_error( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test handles 429 rate limit response correctly.""" + mock_response = MagicMock() + mock_response.status_code = 429 + mock_response.text = "Rate limit exceeded" + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(TooManyRequestsError): + await api.get_items(["B0DLFMFBJW"]) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_handles_invalid_associate_error( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test handles InvalidAssociate error in response body.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "InvalidAssociate: Your credentials are not valid" + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(AssociateValidationError): + await api.get_items(["B0DLFMFBJW"]) + + +class TestAsyncAmazonCreatorsApiThrottling(unittest.IsolatedAsyncioTestCase): + """Tests for throttling mechanism.""" + + @patch("amazon_creatorsapi.async_api.asyncio.sleep") + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_throttling_waits_between_requests( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + mock_sleep: MagicMock, + ) -> None: + """Test that throttling causes wait between consecutive requests.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "itemsResult": {"items": [{"ASIN": "B0DLFMFBJZ"}]} + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + mock_sleep.return_value = None + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0.5, + ) as api: + await api.get_items(["B0DLFMFBJ1"]) + await api.get_items(["B0DLFMFBJ2"]) + + # asyncio.sleep should have been called for throttling + self.assertTrue(mock_sleep.called) + + +class TestAsyncAmazonCreatorsApiGetVariations(unittest.IsolatedAsyncioTestCase): + """Tests for get_variations() method.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_variations_success( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test successful get_variations call.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "variationsResult": { + "VariationSummary": {"PageCount": 1}, + "items": [{"ASIN": "B0DLFMFBJV"}], + } + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + result = await api.get_variations("B0DLFMFBJV") + + self.assertIsNotNone(result) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_variations_with_params( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_variations with optional parameters.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "variationsResult": { + "VariationSummary": {"PageCount": 2}, + "items": [{"ASIN": "B0DLFMFBJV"}], + } + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + result = await api.get_variations( + asin="B0DLFMFBJV", + variation_count=5, + variation_page=1, + condition=Condition.NEW, + currency_of_preference="EUR", + languages_of_preference=["es_ES"], + ) + + self.assertIsNotNone(result) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_variations_not_found( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_variations raises ItemsNotFoundError when no variations found.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(ItemsNotFoundError): + await api.get_variations("B0DLFMFBJV") + + +class TestAsyncAmazonCreatorsApiGetBrowseNodes(unittest.IsolatedAsyncioTestCase): + """Tests for get_browse_nodes() method.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_browse_nodes_success( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test successful get_browse_nodes call.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "browseNodesResult": { + "browseNodes": [{"Id": "123456", "DisplayName": "Electronics"}] + } + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + result = await api.get_browse_nodes(["123456"]) + + self.assertEqual(len(result), 1) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_browse_nodes_with_languages( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_browse_nodes with languages preference.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "browseNodesResult": { + "browseNodes": [{"Id": "123456", "DisplayName": "Electrónica"}] + } + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + result = await api.get_browse_nodes( + browse_node_ids=["123456"], + languages_of_preference=["es_ES"], + ) + + self.assertEqual(len(result), 1) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_browse_nodes_not_found( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_browse_nodes raises ItemsNotFoundError when no nodes found.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(ItemsNotFoundError): + await api.get_browse_nodes(["999999"]) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_get_browse_nodes_empty_nodes_list( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that empty BrowseNodes raises ItemsNotFoundError.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"browseNodesResult": {"browseNodes": None}} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(ItemsNotFoundError): + await api.get_browse_nodes(["123456"]) + + +class TestAsyncAmazonCreatorsApiErrorHandlingExtended(unittest.IsolatedAsyncioTestCase): + """Extended error handling tests.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_handles_invalid_parameter_value_error( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test handles InvalidParameterValue error in response body.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "InvalidParameterValue: The value is not valid" + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(InvalidArgumentError): + await api.get_items(["B0DLFMFBJW"]) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_handles_invalid_partner_tag_error( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test handles InvalidPartnerTag error in response body.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "InvalidPartnerTag: The tag is not valid" + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(InvalidArgumentError): + await api.get_items(["B0DLFMFBJW"]) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_handles_generic_error( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test handles generic error response.""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(RequestError): + await api.get_items(["B0DLFMFBJW"]) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_handles_generic_error_with_empty_body( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test handles generic error with empty response body.""" + mock_response = MagicMock() + mock_response.status_code = 503 + mock_response.text = "" + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(RequestError): + await api.get_items(["B0DLFMFBJW"]) + + +class TestAsyncAmazonCreatorsApiSearchItemsExtended(unittest.IsolatedAsyncioTestCase): + """Extended tests for search_items() method.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_search_items_with_all_params( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test search_items with all optional parameters.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "searchResult": { + "TotalResultCount": 10, + "items": [{"ASIN": "B0DLFMFBJY"}], + } + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + result = await api.search_items( + keywords="laptop", + actor="actor", + artist="artist", + author="author", + brand="brand", + browse_node_id="123", + condition=Condition.NEW, + currency_of_preference="EUR", + item_count=10, + item_page=1, + languages_of_preference=["es_ES"], + max_price=10000, + min_price=100, + min_reviews_rating=4, + min_saving_percent=10, + sort_by=SortBy.PRICE_COLON_LOW_TO_HIGH, + title="laptop", + ) + + self.assertIsNotNone(result) + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_search_items_not_found( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test search_items raises ItemsNotFoundError when no results.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + with self.assertRaises(ItemsNotFoundError): + await api.search_items(keywords="xyznonexistent123") + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_search_items_with_search_index( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test search_items with search_index parameter.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "searchResult": { + "TotalResultCount": 1, + "items": [{"ASIN": "B0DLFMFBJY"}], + } + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) as api: + result = await api.search_items( + keywords="laptop", + search_index="Electronics", + ) + + self.assertIsNotNone(result) + + +class TestAsyncAmazonCreatorsApiWithoutContextManager(unittest.IsolatedAsyncioTestCase): + """Tests for usage without context manager.""" + + @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + async def test_request_without_context_manager( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test making request without context manager creates temp client.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) + + items = await api.get_items(["B0DLFMFBJW"]) + + self.assertEqual(len(items), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/amazon_creatorsapi/async_auth_test.py b/tests/amazon_creatorsapi/async_auth_test.py new file mode 100644 index 0000000..c93c321 --- /dev/null +++ b/tests/amazon_creatorsapi/async_auth_test.py @@ -0,0 +1,298 @@ +"""Unit tests for async OAuth2 token manager.""" + +import time +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +from amazon_creatorsapi.core.async_auth import ( + GRANT_TYPE, + SCOPE, + TOKEN_EXPIRATION_BUFFER, + VERSION_ENDPOINTS, + AsyncOAuth2TokenManager, +) +from amazon_creatorsapi.errors import AuthenticationError + + +class TestAsyncOAuth2TokenManagerInit(unittest.TestCase): + """Tests for AsyncOAuth2TokenManager initialization.""" + + def test_with_version_21(self) -> None: + """Test initialization with version 2.1.""" + manager = AsyncOAuth2TokenManager( + credential_id="test_id", + credential_secret="test_secret", + version="2.1", + ) + + self.assertEqual(manager._credential_id, "test_id") + self.assertEqual(manager._credential_secret, "test_secret") + self.assertEqual(manager._version, "2.1") + self.assertEqual(manager._auth_endpoint, VERSION_ENDPOINTS["2.1"]) + + def test_with_version_22(self) -> None: + """Test initialization with version 2.2.""" + manager = AsyncOAuth2TokenManager( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + ) + + self.assertEqual(manager._auth_endpoint, VERSION_ENDPOINTS["2.2"]) + + def test_with_version_23(self) -> None: + """Test initialization with version 2.3.""" + manager = AsyncOAuth2TokenManager( + credential_id="test_id", + credential_secret="test_secret", + version="2.3", + ) + + self.assertEqual(manager._auth_endpoint, VERSION_ENDPOINTS["2.3"]) + + def test_with_custom_endpoint(self) -> None: + """Test initialization with custom auth endpoint.""" + custom_endpoint = "https://custom.auth.endpoint/token" + manager = AsyncOAuth2TokenManager( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + auth_endpoint=custom_endpoint, + ) + + self.assertEqual(manager._auth_endpoint, custom_endpoint) + + def test_with_invalid_version(self) -> None: + """Test initialization with unsupported version raises ValueError.""" + with self.assertRaises(ValueError) as context: + AsyncOAuth2TokenManager( + credential_id="test_id", + credential_secret="test_secret", + version="1.0", + ) + + self.assertIn("Unsupported version", str(context.exception)) + + +class TestAsyncOAuth2TokenManagerIsTokenValid(unittest.TestCase): + """Tests for is_token_valid() method.""" + + def test_returns_false_when_no_token(self) -> None: + """Test returns False when no token is cached.""" + manager = AsyncOAuth2TokenManager("id", "secret", "2.2") + + self.assertFalse(manager.is_token_valid()) + + def test_returns_false_when_token_expired(self) -> None: + """Test returns False when token has expired.""" + manager = AsyncOAuth2TokenManager("id", "secret", "2.2") + manager._access_token = "expired_token" + manager._expires_at = time.time() - 100 # Expired 100 seconds ago + + self.assertFalse(manager.is_token_valid()) + + def test_returns_true_when_token_valid(self) -> None: + """Test returns True when token is valid and not expired.""" + manager = AsyncOAuth2TokenManager("id", "secret", "2.2") + manager._access_token = "valid_token" + manager._expires_at = time.time() + 3600 # Expires in 1 hour + + self.assertTrue(manager.is_token_valid()) + + +class TestAsyncOAuth2TokenManagerClearToken(unittest.TestCase): + """Tests for clear_token() method.""" + + def test_clears_token_and_expiration(self) -> None: + """Test that clear_token clears both token and expiration.""" + manager = AsyncOAuth2TokenManager("id", "secret", "2.2") + manager._access_token = "some_token" + manager._expires_at = time.time() + 3600 + + manager.clear_token() + + self.assertIsNone(manager._access_token) + self.assertIsNone(manager._expires_at) + + +class TestAsyncOAuth2TokenManagerGetToken(unittest.IsolatedAsyncioTestCase): + """Tests for get_token() method.""" + + async def test_returns_cached_token_when_valid(self) -> None: + """Test returns cached token without refreshing when still valid.""" + manager = AsyncOAuth2TokenManager("id", "secret", "2.2") + manager._access_token = "cached_token" + manager._expires_at = time.time() + 3600 + + with patch.object(manager, "refresh_token") as mock_refresh: + token = await manager.get_token() + + self.assertEqual(token, "cached_token") + mock_refresh.assert_not_called() + + async def test_returns_cached_token_after_lock_double_check(self) -> None: + """Test returns cached token after lock when token became valid. + + This tests the double-check pattern where another coroutine may have + refreshed the token while this one was waiting for the lock. + """ + manager = AsyncOAuth2TokenManager("id", "secret", "2.2") + + # Initially no token, but we'll set it after first is_token_valid check + call_count = 0 + + def mock_is_token_valid() -> bool: + nonlocal call_count + call_count += 1 + if call_count == 1: + # First check: no token + return False + # After lock: token is now valid (set by another coroutine) + manager._access_token = "token_from_other_coroutine" + manager._expires_at = time.time() + 3600 + return True + + with patch.object(manager, "is_token_valid", side_effect=mock_is_token_valid): + with patch.object(manager, "refresh_token") as mock_refresh: + token = await manager.get_token() + + self.assertEqual(token, "token_from_other_coroutine") + mock_refresh.assert_not_called() + + @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + async def test_refreshes_token_when_expired( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test refreshes token when expired.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "new_token", + "expires_in": 3600, + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager("test_id", "test_secret", "2.2") + + token = await manager.get_token() + + self.assertEqual(token, "new_token") + self.assertEqual(manager._access_token, "new_token") + self.assertIsNotNone(manager._expires_at) + + +class TestAsyncOAuth2TokenManagerRefreshToken(unittest.IsolatedAsyncioTestCase): + """Tests for refresh_token() method.""" + + @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + async def test_successful_token_refresh( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test successful token refresh.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "fresh_token", + "expires_in": 7200, + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager("test_id", "test_secret", "2.2") + current_time = time.time() + + token = await manager.refresh_token() + + self.assertEqual(token, "fresh_token") + self.assertEqual(manager._access_token, "fresh_token") + expected_expiration = current_time + 7200 - TOKEN_EXPIRATION_BUFFER + self.assertIsNotNone(manager._expires_at) + assert manager._expires_at is not None # for mypy + self.assertAlmostEqual( + manager._expires_at, + expected_expiration, + delta=2, # Allow 2 second tolerance + ) + + # Verify correct request was made + call_args = mock_client.post.call_args + self.assertIn(GRANT_TYPE, str(call_args)) + self.assertIn(SCOPE, str(call_args)) + + @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + async def test_raises_error_on_non_200_response( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test raises AuthenticationError on non-200 response.""" + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager("test_id", "test_secret", "2.2") + + with self.assertRaises(AuthenticationError) as context: + await manager.refresh_token() + + self.assertIn("401", str(context.exception)) + + @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + async def test_raises_error_when_no_access_token_in_response( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test raises AuthenticationError when response has no access_token.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"error": "invalid_scope"} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager("test_id", "test_secret", "2.2") + + with self.assertRaises(AuthenticationError) as context: + await manager.refresh_token() + + self.assertIn("No access token", str(context.exception)) + + @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + async def test_raises_error_on_request_error( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test raises AuthenticationError on httpx.RequestError.""" + + mock_client = AsyncMock() + mock_client.post.side_effect = httpx.RequestError("Connection failed") + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager("test_id", "test_secret", "2.2") + + with self.assertRaises(AuthenticationError) as context: + await manager.refresh_token() + + self.assertIn("token request failed", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/amazon_creatorsapi/async_client_test.py b/tests/amazon_creatorsapi/async_client_test.py new file mode 100644 index 0000000..6bcc5ff --- /dev/null +++ b/tests/amazon_creatorsapi/async_client_test.py @@ -0,0 +1,168 @@ +"""Unit tests for async HTTP client.""" + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from amazon_creatorsapi.core.async_client import ( + DEFAULT_HOST, + DEFAULT_TIMEOUT, + USER_AGENT, + AsyncHttpClient, + AsyncHttpResponse, +) + + +class TestAsyncHttpResponse(unittest.TestCase): + """Tests for AsyncHttpResponse dataclass.""" + + def test_json_parsing(self) -> None: + """Test that json() correctly parses response body.""" + response = AsyncHttpResponse( + status_code=200, + headers={"content-type": "application/json"}, + body=b'{"key": "value"}', + text='{"key": "value"}', + ) + + result = response.json() + + self.assertEqual(result, {"key": "value"}) + + def test_attributes(self) -> None: + """Test that all attributes are correctly set.""" + response = AsyncHttpResponse( + status_code=404, + headers={"x-custom": "header"}, + body=b"test body", + text="test body", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.headers, {"x-custom": "header"}) + self.assertEqual(response.body, b"test body") + self.assertEqual(response.text, "test body") + + +class TestAsyncHttpClientInit(unittest.TestCase): + """Tests for AsyncHttpClient initialization.""" + + def test_default_values(self) -> None: + """Test that default values are correctly set.""" + client = AsyncHttpClient() + + self.assertEqual(client._host, DEFAULT_HOST) + self.assertEqual(client._timeout, DEFAULT_TIMEOUT) + self.assertIsNone(client._client) + self.assertFalse(client._owns_client) + + def test_custom_values(self) -> None: + """Test that custom values are correctly set.""" + client = AsyncHttpClient( + host="https://custom.host", + timeout=60.0, + ) + + self.assertEqual(client._host, "https://custom.host") + self.assertEqual(client._timeout, 60.0) + + +class TestAsyncHttpClientContextManager(unittest.IsolatedAsyncioTestCase): + """Tests for AsyncHttpClient async context manager.""" + + @patch("amazon_creatorsapi.core.async_client.httpx.AsyncClient") + async def test_aenter_creates_client( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test that __aenter__ creates an httpx.AsyncClient.""" + mock_client = AsyncMock() + mock_async_client_class.return_value = mock_client + + client = AsyncHttpClient() + + result = await client.__aenter__() + + mock_async_client_class.assert_called_once() + self.assertIs(result, client) + self.assertTrue(client._owns_client) + + @patch("amazon_creatorsapi.core.async_client.httpx.AsyncClient") + async def test_aexit_closes_client( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test that __aexit__ closes the client.""" + mock_client = AsyncMock() + mock_async_client_class.return_value = mock_client + + client = AsyncHttpClient() + await client.__aenter__() + await client.__aexit__(None, None, None) + + mock_client.aclose.assert_called_once() + self.assertIsNone(client._client) + self.assertFalse(client._owns_client) + + +class TestAsyncHttpClientPost(unittest.IsolatedAsyncioTestCase): + """Tests for AsyncHttpClient.post() method.""" + + @patch("amazon_creatorsapi.core.async_client.httpx.AsyncClient") + async def test_post_with_context_manager( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test POST request using context manager (persistent client).""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.content = b'{"result": "ok"}' + mock_response.text = '{"result": "ok"}' + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_async_client_class.return_value = mock_client + + async with AsyncHttpClient() as client: + response = await client.post( + "/test/path", + {"Authorization": "Bearer token"}, + {"data": "value"}, + ) + + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + self.assertEqual(call_args[0][0], "/test/path") + self.assertIn("Authorization", call_args[1]["headers"]) + self.assertIn(USER_AGENT, call_args[1]["headers"]["User-Agent"]) + self.assertEqual(call_args[1]["json"], {"data": "value"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"result": "ok"}) + + @patch("amazon_creatorsapi.core.async_client.httpx.AsyncClient") + async def test_post_without_context_manager( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test POST request without context manager (creates new client).""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.content = b'{"standalone": true}' + mock_response.text = '{"standalone": true}' + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + client = AsyncHttpClient() + response = await client.post("/test", {}, {"query": "test"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"standalone": True}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/amazon_creatorsapi/async_integration_test.py b/tests/amazon_creatorsapi/async_integration_test.py new file mode 100644 index 0000000..20db78d --- /dev/null +++ b/tests/amazon_creatorsapi/async_integration_test.py @@ -0,0 +1,469 @@ +"""Integration tests for AsyncAmazonCreatorsApi class.""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +from pathlib import Path +from typing import TYPE_CHECKING +from unittest import IsolatedAsyncioTestCase, skipUnless + +from dotenv import load_dotenv + +from amazon_creatorsapi import AsyncAmazonCreatorsApi +from amazon_creatorsapi.errors import ItemsNotFoundError + +if TYPE_CHECKING: + from creatorsapi_python_sdk.models.browse_node import BrowseNode + from creatorsapi_python_sdk.models.item import Item + from creatorsapi_python_sdk.models.search_result import SearchResult + from creatorsapi_python_sdk.models.variations_result import VariationsResult + +# Load environment variables from .env file +load_dotenv(Path(__file__).parents[2] / ".env") + + +def get_api_credentials() -> tuple[ + str | None, str | None, str | None, str | None, str | None, str | None +]: + """Get API credentials from environment variables. + + Returns: + Tuple of credentials, with None for missing values. + """ + return ( + os.environ.get("CREDENTIAL_ID"), + os.environ.get("CREDENTIAL_SECRET"), + os.environ.get("API_VERSION"), + os.environ.get("AFFILIATE_TAG"), + os.environ.get("MARKETPLACE"), + os.environ.get("COUNTRY_CODE"), + ) + + +def has_api_credentials() -> bool: + """Check if all API credentials are available.""" + ( + credential_id, + credential_secret, + api_version, + affiliate_tag, + marketplace, + country_code, + ) = get_api_credentials() + + # Need critical credentials + if not all([credential_id, credential_secret, api_version, affiliate_tag]): + return False + + # Need at least marketplace or country_code + return bool(marketplace or country_code) + + +def _has_valid_offer(item: Item) -> bool: + """Check if an item has a valid offer with price and availability.""" + if item.offers_v2 is None or not item.offers_v2.listings: + return False + + listing = item.offers_v2.listings[0] + + has_price = ( + listing.price is not None + and listing.price.money is not None + and listing.price.money.amount is not None + ) + + is_available = ( + listing.availability is None + or listing.availability.type is None + or listing.availability.type != "OutOfStock" + ) + + return has_price and is_available + + +def _find_item_with_offers(items: list[Item], search_result: SearchResult) -> Item: + """Find an item with offers, price, and in stock.""" + return next( + (item for item in items if _has_valid_offer(item)), + items[0] if items else search_result.items[0], # type: ignore[index] + ) + + +def _find_variation_asin(items: list[Item], search_result: SearchResult) -> str | None: + """Find ASIN to use for variations lookup.""" + item_with_variations = next( + (item for item in items if item.parent_asin), + None, + ) + + if item_with_variations: + return item_with_variations.parent_asin + + if search_result.items: + return search_result.items[0].asin + + return None + + +async def _run_api_setup() -> dict[str, object]: + """Run all API calls once and return cached data.""" + ( + credential_id, + credential_secret, + api_version, + affiliate_tag, + marketplace, + country_code, + ) = get_api_credentials() + + api = AsyncAmazonCreatorsApi( + credential_id=credential_id, # type: ignore[arg-type] + credential_secret=credential_secret, # type: ignore[arg-type] + version=api_version, # type: ignore[arg-type] + tag=affiliate_tag, # type: ignore[arg-type] + marketplace=marketplace, + country=country_code, # type: ignore[arg-type] + throttling=1, + ) + + data: dict[str, object] = {} + + async with api: + # 1. Search items + search_result = await api.search_items(keywords="laptop") + items = search_result.items or [] + + # 2. Find item with offers + item_with_offers = _find_item_with_offers(items, search_result) + + # 3. Get items by ASIN + get_items_result: list[Item] = [] + if item_with_offers.asin: + get_items_result = await api.get_items([item_with_offers.asin]) + + # 4. Get variations + variations_result: VariationsResult | None = None + target_asin = _find_variation_asin(items, search_result) + if target_asin: + with contextlib.suppress(ItemsNotFoundError): + variations_result = await api.get_variations(target_asin) + + # 5. Get browse nodes + browse_nodes_result: list[BrowseNode] = [] + item_with_browse_nodes = next( + ( + item + for item in items + if item.browse_node_info and item.browse_node_info.browse_nodes + ), + None, + ) + if item_with_browse_nodes: + browse_node_info = item_with_browse_nodes.browse_node_info + if browse_node_info and browse_node_info.browse_nodes: + browse_node_id = browse_node_info.browse_nodes[0].id + if browse_node_id: + browse_nodes_result = await api.get_browse_nodes([browse_node_id]) + + # Store in data dict + data["affiliate_tag"] = affiliate_tag + data["search_result"] = search_result + data["item_with_offers"] = item_with_offers + data["get_items_result"] = get_items_result + data["variations_result"] = variations_result + data["browse_nodes_result"] = browse_nodes_result + + return data + + +# Module-level cache - run setup once when module is loaded (only if credentials exist) +_cached_data: dict[str, object] = {} +if has_api_credentials(): + _cached_data = asyncio.run(_run_api_setup()) + + +@skipUnless(has_api_credentials(), "Needs Amazon Creators API credentials") +class AsyncIntegrationTest(IsolatedAsyncioTestCase): + """Integration tests that make real async API calls to Amazon Creators API. + + All API results are cached at module level to minimize the number of + requests. This reduces costs and avoids rate limiting. + """ + + NO_VARIATIONS_FOUND_MSG = "No variations found" + + def setUp(self) -> None: + """Set up that runs before each test - loads cached data.""" + self.affiliate_tag: str = _cached_data["affiliate_tag"] # type: ignore[assignment] + self.search_result: SearchResult = _cached_data["search_result"] # type: ignore[assignment] + self.item_with_offers: Item = _cached_data["item_with_offers"] # type: ignore[assignment] + self.get_items_result: list[Item] = _cached_data["get_items_result"] # type: ignore[assignment] + self.variations_result: VariationsResult | None = _cached_data[ + "variations_result" + ] # type: ignore[assignment] + self.browse_nodes_result: list[BrowseNode] = _cached_data["browse_nodes_result"] # type: ignore[assignment] + + async def test_search_items_returns_expected_count(self) -> None: + """Test that search returns the default number of items.""" + items = self.search_result.items + if items: + self.assertEqual(10, len(items)) + + async def test_search_items_includes_affiliate_tag(self) -> None: + """Test that search results include the affiliate tag in URLs.""" + if self.search_result.items: + searched_item = self.search_result.items[0] + if searched_item.detail_page_url: + self.assertIn(self.affiliate_tag, searched_item.detail_page_url) + + async def test_search_items_returns_offers_v2(self) -> None: + """Test that search results include OffersV2 data.""" + items = self.search_result.items + self.assertIsNotNone(items) + if items: + self.assertGreater(len(items), 0) + + item = self.item_with_offers + self.assertIsNotNone(item.offers_v2) + + async def test_offers_v2_listing_has_price_info(self) -> None: + """Test that OffersV2 listings include price information.""" + item = self.item_with_offers + self.assertIsNotNone(item.offers_v2) + + if item.offers_v2 and item.offers_v2.listings: + listing = item.offers_v2.listings[0] + self.assertIsNotNone(listing.price) + + if listing.price and listing.price.money: + self.assertIsNotNone(listing.price.money.amount) + self.assertIsNotNone(listing.price.money.currency) + self.assertIsNotNone(listing.price.money.display_amount) + + async def test_offers_v2_listing_has_merchant_info(self) -> None: + """Test that OffersV2 listings include merchant information.""" + item = self.item_with_offers + self.assertIsNotNone(item.offers_v2) + + if item.offers_v2 and item.offers_v2.listings: + listing = item.offers_v2.listings[0] + if listing.merchant_info: + self.assertIsNotNone(listing.merchant_info.name) + + async def test_offers_v2_listing_has_condition(self) -> None: + """Test that OffersV2 listings include condition information.""" + item = self.item_with_offers + self.assertIsNotNone(item.offers_v2) + + if item.offers_v2 and item.offers_v2.listings: + listing = item.offers_v2.listings[0] + if listing.condition: + self.assertIsNotNone(listing.condition.value) + + async def test_offers_v2_listing_has_availability(self) -> None: + """Test that OffersV2 listings include availability information.""" + item = self.item_with_offers + self.assertIsNotNone(item.offers_v2) + + if item.offers_v2 and item.offers_v2.listings: + listing = item.offers_v2.listings[0] + if listing.availability: + self.assertIsNotNone(listing.availability.type) + + async def test_get_items_returns_single_result(self) -> None: + """Test that get_items returns exactly one item when given one ASIN.""" + result = self.get_items_result + self.assertEqual(1, len(result)) + + async def test_get_items_includes_affiliate_tag(self) -> None: + """Test that get_items results include the affiliate tag in URLs.""" + if self.get_items_result: + detail_url = self.get_items_result[0].detail_page_url + if detail_url: + self.assertIn(self.affiliate_tag, detail_url) + + async def test_get_items_returns_offers_v2(self) -> None: + """Test that get_items returns OffersV2 data with price details.""" + if self.get_items_result: + item = self.get_items_result[0] + self.assertIsNotNone(item.offers_v2) + + if item.offers_v2 and item.offers_v2.listings: + listing = item.offers_v2.listings[0] + self.assertIsNotNone(listing) + + if listing.price and listing.price.money: + self.assertIsNotNone(listing.price.money.amount) + self.assertIsNotNone(listing.price.money.display_amount) + + async def test_get_variations_returns_items(self) -> None: + """Test that get_variations returns a list of variation items.""" + if self.variations_result: + self.assertIsNotNone(self.variations_result) + items = self.variations_result.items + self.assertIsNotNone(items) + if items: + self.assertGreater(len(items), 0) + else: + self.skipTest(self.NO_VARIATIONS_FOUND_MSG) + + async def test_get_variations_returns_variation_summary(self) -> None: + """Test that get_variations returns variation summary.""" + if self.variations_result: + summary = self.variations_result.variation_summary + self.assertIsNotNone(summary) + if summary: + self.assertIsNotNone(summary.variation_count) + self.assertGreater(summary.variation_count, 0) # type: ignore[arg-type] + else: + self.skipTest(self.NO_VARIATIONS_FOUND_MSG) + + async def test_get_variations_items_include_affiliate_tag(self) -> None: + """Test that variation items include the affiliate tag in URLs.""" + if self.variations_result: + items = self.variations_result.items + if items: + item = items[0] + if item.detail_page_url: + self.assertIn(self.affiliate_tag, item.detail_page_url) + else: + self.skipTest(self.NO_VARIATIONS_FOUND_MSG) + + async def test_get_browse_nodes_returns_results(self) -> None: + """Test that get_browse_nodes returns browse node information.""" + self.assertGreater(len(self.browse_nodes_result), 0) + + async def test_get_browse_nodes_returns_node_info(self) -> None: + """Test that browse nodes contain expected information.""" + if self.browse_nodes_result: + node = self.browse_nodes_result[0] + self.assertIsNotNone(node.id) + self.assertIsNotNone(node.display_name) + + async def test_offers_v2_listing_has_is_buy_box_winner(self) -> None: + """Test that OffersV2 listings include is_buy_box_winner attribute.""" + item = self.item_with_offers + self.assertIsNotNone(item.offers_v2) + + if item.offers_v2 and item.offers_v2.listings: + listing = item.offers_v2.listings[0] + self.assertIsInstance(listing.is_buy_box_winner, bool) + + async def test_offers_v2_listing_has_type(self) -> None: + """Test that OffersV2 listings include offer type.""" + item = self.item_with_offers + self.assertIsNotNone(item.offers_v2) + + if item.offers_v2 and item.offers_v2.listings: + listing = item.offers_v2.listings[0] + if listing.type: + self.assertIsNotNone(listing.type) + + async def test_offers_v2_price_has_savings_when_available(self) -> None: + """Test that OffersV2 price includes savings info when available.""" + item = self.item_with_offers + self.assertIsNotNone(item.offers_v2) + + if item.offers_v2 and item.offers_v2.listings: + listing = item.offers_v2.listings[0] + if listing.price and listing.price.savings: + savings = listing.price.savings + if savings.money: + self.assertIsNotNone(savings.money.amount) + if savings.percentage is not None: + self.assertIsInstance(savings.percentage, (int, float)) + + async def test_search_items_returns_item_info(self) -> None: + """Test that search results include item info with title.""" + if self.search_result.items: + item = self.search_result.items[0] + self.assertIsNotNone(item.item_info) + if item.item_info: + self.assertIsNotNone(item.item_info.title) + if item.item_info.title: + self.assertIsNotNone(item.item_info.title.display_value) + title_display = item.item_info.title.display_value + self.assertIsNotNone(title_display) + self.assertIsInstance(title_display, str) + if title_display: + self.assertGreater(len(title_display), 0) + + async def test_search_items_returns_valid_asin(self) -> None: + """Test that search results return valid ASIN format.""" + if self.search_result.items: + item = self.search_result.items[0] + self.assertIsNotNone(item.asin) + if item.asin: + self.assertEqual(len(item.asin), 10) + self.assertTrue(item.asin.isalnum()) + + async def test_search_items_returns_images(self) -> None: + """Test that search results include product images.""" + if self.search_result.items: + item = self.search_result.items[0] + self.assertIsNotNone(item.images) + if item.images and item.images.primary: + self.assertIsNotNone(item.images.primary.large) + if item.images.primary.large: + large_url = item.images.primary.large.url + self.assertIsNotNone(large_url) + if large_url: + self.assertTrue(large_url.startswith("http")) + + async def test_get_items_returns_item_info(self) -> None: + """Test that get_items returns item info with title.""" + if self.get_items_result: + item = self.get_items_result[0] + self.assertIsNotNone(item.item_info) + if item.item_info and item.item_info.title: + self.assertIsNotNone(item.item_info.title.display_value) + + async def test_get_variations_returns_offers_v2(self) -> None: + """Test that get_variations returns OffersV2 data for variation items.""" + if self.variations_result and self.variations_result.items: + item_with_offers = next( + (item for item in self.variations_result.items if item.offers_v2), + None, + ) + if item_with_offers and item_with_offers.offers_v2: + self.assertIsNotNone(item_with_offers.offers_v2) + if item_with_offers.offers_v2.listings: + listing = item_with_offers.offers_v2.listings[0] + self.assertIsNotNone(listing) + else: + self.skipTest(self.NO_VARIATIONS_FOUND_MSG) + + async def test_context_manager_works_correctly(self) -> None: + """Test that async context manager works for connection pooling.""" + ( + credential_id, + credential_secret, + api_version, + affiliate_tag, + marketplace, + country_code, + ) = get_api_credentials() + + api = AsyncAmazonCreatorsApi( + credential_id=credential_id, # type: ignore[arg-type] + credential_secret=credential_secret, # type: ignore[arg-type] + version=api_version, # type: ignore[arg-type] + tag=affiliate_tag, # type: ignore[arg-type] + marketplace=marketplace, + country=country_code, # type: ignore[arg-type] + throttling=1, + ) + + async with api: + # Make a simple API call inside context manager + result = await api.search_items(keywords="book", item_count=1) + self.assertIsNotNone(result) + self.assertIsNotNone(result.items) + + +if __name__ == "__main__": + import unittest + + unittest.main() From ad4b5902556e23ac502bddf4b9790e6128e51143 Mon Sep 17 00:00:00 2001 From: Sergio Abad Date: Wed, 4 Feb 2026 23:00:55 +0100 Subject: [PATCH 2/5] refactor: move async API and its related import logic to a dedicated subpackage. --- amazon_creatorsapi/__init__.py | 8 - amazon_creatorsapi/aio/__init__.py | 14 ++ .../{async_api.py => aio/api.py} | 10 +- .../{core/async_auth.py => aio/auth.py} | 0 .../{core/async_client.py => aio/client.py} | 0 tests/amazon_creatorsapi/aio/__init__.py | 1 + .../{async_api_test.py => aio/api_test.py} | 110 ++++++------ .../{async_auth_test.py => aio/auth_test.py} | 12 +- .../integration_test.py} | 7 +- tests/amazon_creatorsapi/async_client_test.py | 168 ------------------ 10 files changed, 84 insertions(+), 246 deletions(-) create mode 100644 amazon_creatorsapi/aio/__init__.py rename amazon_creatorsapi/{async_api.py => aio/api.py} (98%) rename amazon_creatorsapi/{core/async_auth.py => aio/auth.py} (100%) rename amazon_creatorsapi/{core/async_client.py => aio/client.py} (100%) create mode 100644 tests/amazon_creatorsapi/aio/__init__.py rename tests/amazon_creatorsapi/{async_api_test.py => aio/api_test.py} (90%) rename tests/amazon_creatorsapi/{async_auth_test.py => aio/auth_test.py} (96%) rename tests/amazon_creatorsapi/{async_integration_test.py => aio/integration_test.py} (98%) delete mode 100644 tests/amazon_creatorsapi/async_client_test.py diff --git a/amazon_creatorsapi/__init__.py b/amazon_creatorsapi/__init__.py index b89eea1..c02ab1f 100644 --- a/amazon_creatorsapi/__init__.py +++ b/amazon_creatorsapi/__init__.py @@ -3,17 +3,9 @@ A Python wrapper for the Amazon Creators API. """ -from importlib.util import find_spec - __author__ = "Sergio Abad" __all__ = ["AmazonCreatorsApi", "Country", "models"] from . import models from .api import AmazonCreatorsApi from .core import Country - -# Async support (requires 'async' extra: pip install python-amazon-paapi[async]) -if find_spec("httpx") is not None: - from .async_api import AsyncAmazonCreatorsApi - - __all__ += ["AsyncAmazonCreatorsApi"] diff --git a/amazon_creatorsapi/aio/__init__.py b/amazon_creatorsapi/aio/__init__.py new file mode 100644 index 0000000..cfc69d4 --- /dev/null +++ b/amazon_creatorsapi/aio/__init__.py @@ -0,0 +1,14 @@ +"""Async support for Amazon Creators API.""" + +try: + import httpx # noqa: F401 +except ImportError as exc: + msg = ( + "httpx is required for async support. " + "Install with: pip install python-amazon-paapi[async]" + ) + raise ImportError(msg) from exc + +from amazon_creatorsapi.aio.api import AsyncAmazonCreatorsApi + +__all__ = ["AsyncAmazonCreatorsApi"] diff --git a/amazon_creatorsapi/async_api.py b/amazon_creatorsapi/aio/api.py similarity index 98% rename from amazon_creatorsapi/async_api.py rename to amazon_creatorsapi/aio/api.py index 7ecf1f9..a10e764 100644 --- a/amazon_creatorsapi/async_api.py +++ b/amazon_creatorsapi/aio/api.py @@ -23,8 +23,8 @@ ) try: - from amazon_creatorsapi.core.async_auth import AsyncOAuth2TokenManager - from amazon_creatorsapi.core.async_client import AsyncHttpClient + from .auth import AsyncOAuth2TokenManager + from .client import AsyncHttpClient except ImportError as exc: # pragma: no cover msg = ( "httpx is required for async support. " @@ -103,7 +103,7 @@ class AsyncAmazonCreatorsApi: """ - def __init__( # noqa: PLR0913 + def __init__( self, credential_id: str, credential_secret: str, @@ -213,7 +213,7 @@ async def get_items( return self._deserialize_items(items_result["items"]) - async def search_items( # noqa: PLR0912, PLR0913, C901 + async def search_items( # noqa: PLR0912, C901 self, keywords: str | None = None, actor: str | None = None, @@ -323,7 +323,7 @@ async def search_items( # noqa: PLR0912, PLR0913, C901 return self._deserialize_search_result(search_result) - async def get_variations( # noqa: PLR0913 + async def get_variations( self, asin: str, variation_count: int | None = None, diff --git a/amazon_creatorsapi/core/async_auth.py b/amazon_creatorsapi/aio/auth.py similarity index 100% rename from amazon_creatorsapi/core/async_auth.py rename to amazon_creatorsapi/aio/auth.py diff --git a/amazon_creatorsapi/core/async_client.py b/amazon_creatorsapi/aio/client.py similarity index 100% rename from amazon_creatorsapi/core/async_client.py rename to amazon_creatorsapi/aio/client.py diff --git a/tests/amazon_creatorsapi/aio/__init__.py b/tests/amazon_creatorsapi/aio/__init__.py new file mode 100644 index 0000000..92f004c --- /dev/null +++ b/tests/amazon_creatorsapi/aio/__init__.py @@ -0,0 +1 @@ +# Empty init file for async test module diff --git a/tests/amazon_creatorsapi/async_api_test.py b/tests/amazon_creatorsapi/aio/api_test.py similarity index 90% rename from tests/amazon_creatorsapi/async_api_test.py rename to tests/amazon_creatorsapi/aio/api_test.py index aa41ad6..fc6025a 100644 --- a/tests/amazon_creatorsapi/async_api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -3,7 +3,7 @@ import unittest from unittest.mock import AsyncMock, MagicMock, patch -from amazon_creatorsapi.async_api import ( +from amazon_creatorsapi.aio import ( AsyncAmazonCreatorsApi, ) from amazon_creatorsapi.errors import ( @@ -20,7 +20,7 @@ class TestAsyncAmazonCreatorsApiInit(unittest.TestCase): """Tests for AsyncAmazonCreatorsApi initialization.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") def test_with_country_code(self, mock_token_manager: MagicMock) -> None: """Test initialization with country code.""" api = AsyncAmazonCreatorsApi( @@ -35,7 +35,7 @@ def test_with_country_code(self, mock_token_manager: MagicMock) -> None: self.assertEqual(api.marketplace, "www.amazon.es") self.assertEqual(api.throttling, 1.0) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") def test_with_marketplace(self, mock_token_manager: MagicMock) -> None: """Test initialization with explicit marketplace.""" api = AsyncAmazonCreatorsApi( @@ -48,7 +48,7 @@ def test_with_marketplace(self, mock_token_manager: MagicMock) -> None: self.assertEqual(api.marketplace, "www.amazon.co.uk") - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") def test_with_custom_throttling(self, mock_token_manager: MagicMock) -> None: """Test initialization with custom throttling value.""" api = AsyncAmazonCreatorsApi( @@ -62,7 +62,7 @@ def test_with_custom_throttling(self, mock_token_manager: MagicMock) -> None: self.assertEqual(api.throttling, 2.5) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") def test_raises_error_when_no_country_or_marketplace( self, mock_token_manager: MagicMock ) -> None: @@ -77,7 +77,7 @@ def test_raises_error_when_no_country_or_marketplace( self.assertIn("Either 'country' or 'marketplace'", str(context.exception)) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") def test_raises_error_for_invalid_country( self, mock_token_manager: MagicMock ) -> None: @@ -97,8 +97,8 @@ def test_raises_error_for_invalid_country( class TestAsyncAmazonCreatorsApiContextManager(unittest.IsolatedAsyncioTestCase): """Tests for AsyncAmazonCreatorsApi async context manager.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_context_manager_creates_and_closes_client( self, mock_http_client_class: MagicMock, @@ -124,8 +124,8 @@ async def test_context_manager_creates_and_closes_client( class TestAsyncAmazonCreatorsApiGetItems(unittest.IsolatedAsyncioTestCase): """Tests for get_items() method.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_items_success( self, mock_http_client_class: MagicMock, @@ -166,8 +166,8 @@ async def test_get_items_success( self.assertEqual(len(items), 1) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_items_not_found( self, mock_http_client_class: MagicMock, @@ -198,8 +198,8 @@ async def test_get_items_not_found( with self.assertRaises(ItemsNotFoundError): await api.get_items(["B0DLFMFBJX"]) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_items_with_optional_params( self, mock_http_client_class: MagicMock, @@ -242,8 +242,8 @@ async def test_get_items_with_optional_params( class TestAsyncAmazonCreatorsApiSearchItems(unittest.IsolatedAsyncioTestCase): """Tests for search_items() method.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_search_items_success( self, mock_http_client_class: MagicMock, @@ -284,8 +284,8 @@ async def test_search_items_success( class TestAsyncAmazonCreatorsApiErrorHandling(unittest.IsolatedAsyncioTestCase): """Tests for error handling.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_handles_404_error( self, mock_http_client_class: MagicMock, @@ -316,8 +316,8 @@ async def test_handles_404_error( with self.assertRaises(ItemsNotFoundError): await api.get_items(["B0DLFMFBJW"]) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_handles_429_error( self, mock_http_client_class: MagicMock, @@ -348,8 +348,8 @@ async def test_handles_429_error( with self.assertRaises(TooManyRequestsError): await api.get_items(["B0DLFMFBJW"]) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_handles_invalid_associate_error( self, mock_http_client_class: MagicMock, @@ -384,9 +384,9 @@ async def test_handles_invalid_associate_error( class TestAsyncAmazonCreatorsApiThrottling(unittest.IsolatedAsyncioTestCase): """Tests for throttling mechanism.""" - @patch("amazon_creatorsapi.async_api.asyncio.sleep") - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.asyncio.sleep") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_throttling_waits_between_requests( self, mock_http_client_class: MagicMock, @@ -429,8 +429,8 @@ async def test_throttling_waits_between_requests( class TestAsyncAmazonCreatorsApiGetVariations(unittest.IsolatedAsyncioTestCase): """Tests for get_variations() method.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_variations_success( self, mock_http_client_class: MagicMock, @@ -467,8 +467,8 @@ async def test_get_variations_success( self.assertIsNotNone(result) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_variations_with_params( self, mock_http_client_class: MagicMock, @@ -512,8 +512,8 @@ async def test_get_variations_with_params( self.assertIsNotNone(result) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_variations_not_found( self, mock_http_client_class: MagicMock, @@ -548,8 +548,8 @@ async def test_get_variations_not_found( class TestAsyncAmazonCreatorsApiGetBrowseNodes(unittest.IsolatedAsyncioTestCase): """Tests for get_browse_nodes() method.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_browse_nodes_success( self, mock_http_client_class: MagicMock, @@ -585,8 +585,8 @@ async def test_get_browse_nodes_success( self.assertEqual(len(result), 1) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_browse_nodes_with_languages( self, mock_http_client_class: MagicMock, @@ -625,8 +625,8 @@ async def test_get_browse_nodes_with_languages( self.assertEqual(len(result), 1) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_browse_nodes_not_found( self, mock_http_client_class: MagicMock, @@ -657,8 +657,8 @@ async def test_get_browse_nodes_not_found( with self.assertRaises(ItemsNotFoundError): await api.get_browse_nodes(["999999"]) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_browse_nodes_empty_nodes_list( self, mock_http_client_class: MagicMock, @@ -693,8 +693,8 @@ async def test_get_browse_nodes_empty_nodes_list( class TestAsyncAmazonCreatorsApiErrorHandlingExtended(unittest.IsolatedAsyncioTestCase): """Extended error handling tests.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_handles_invalid_parameter_value_error( self, mock_http_client_class: MagicMock, @@ -725,8 +725,8 @@ async def test_handles_invalid_parameter_value_error( with self.assertRaises(InvalidArgumentError): await api.get_items(["B0DLFMFBJW"]) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_handles_invalid_partner_tag_error( self, mock_http_client_class: MagicMock, @@ -757,8 +757,8 @@ async def test_handles_invalid_partner_tag_error( with self.assertRaises(InvalidArgumentError): await api.get_items(["B0DLFMFBJW"]) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_handles_generic_error( self, mock_http_client_class: MagicMock, @@ -789,8 +789,8 @@ async def test_handles_generic_error( with self.assertRaises(RequestError): await api.get_items(["B0DLFMFBJW"]) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_handles_generic_error_with_empty_body( self, mock_http_client_class: MagicMock, @@ -825,8 +825,8 @@ async def test_handles_generic_error_with_empty_body( class TestAsyncAmazonCreatorsApiSearchItemsExtended(unittest.IsolatedAsyncioTestCase): """Extended tests for search_items() method.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_search_items_with_all_params( self, mock_http_client_class: MagicMock, @@ -881,8 +881,8 @@ async def test_search_items_with_all_params( self.assertIsNotNone(result) - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_search_items_not_found( self, mock_http_client_class: MagicMock, @@ -913,8 +913,8 @@ async def test_search_items_not_found( with self.assertRaises(ItemsNotFoundError): await api.search_items(keywords="xyznonexistent123") - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_search_items_with_search_index( self, mock_http_client_class: MagicMock, @@ -958,8 +958,8 @@ async def test_search_items_with_search_index( class TestAsyncAmazonCreatorsApiWithoutContextManager(unittest.IsolatedAsyncioTestCase): """Tests for usage without context manager.""" - @patch("amazon_creatorsapi.async_api.AsyncOAuth2TokenManager") - @patch("amazon_creatorsapi.async_api.AsyncHttpClient") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_request_without_context_manager( self, mock_http_client_class: MagicMock, diff --git a/tests/amazon_creatorsapi/async_auth_test.py b/tests/amazon_creatorsapi/aio/auth_test.py similarity index 96% rename from tests/amazon_creatorsapi/async_auth_test.py rename to tests/amazon_creatorsapi/aio/auth_test.py index c93c321..7cf5abb 100644 --- a/tests/amazon_creatorsapi/async_auth_test.py +++ b/tests/amazon_creatorsapi/aio/auth_test.py @@ -6,7 +6,7 @@ import httpx -from amazon_creatorsapi.core.async_auth import ( +from amazon_creatorsapi.aio.auth import ( GRANT_TYPE, SCOPE, TOKEN_EXPIRATION_BUFFER, @@ -161,7 +161,7 @@ def mock_is_token_valid() -> bool: self.assertEqual(token, "token_from_other_coroutine") mock_refresh.assert_not_called() - @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") async def test_refreshes_token_when_expired( self, mock_async_client_class: MagicMock, @@ -191,7 +191,7 @@ async def test_refreshes_token_when_expired( class TestAsyncOAuth2TokenManagerRefreshToken(unittest.IsolatedAsyncioTestCase): """Tests for refresh_token() method.""" - @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") async def test_successful_token_refresh( self, mock_async_client_class: MagicMock, @@ -230,7 +230,7 @@ async def test_successful_token_refresh( self.assertIn(GRANT_TYPE, str(call_args)) self.assertIn(SCOPE, str(call_args)) - @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") async def test_raises_error_on_non_200_response( self, mock_async_client_class: MagicMock, @@ -252,7 +252,7 @@ async def test_raises_error_on_non_200_response( self.assertIn("401", str(context.exception)) - @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") async def test_raises_error_when_no_access_token_in_response( self, mock_async_client_class: MagicMock, @@ -274,7 +274,7 @@ async def test_raises_error_when_no_access_token_in_response( self.assertIn("No access token", str(context.exception)) - @patch("amazon_creatorsapi.core.async_auth.httpx.AsyncClient") + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") async def test_raises_error_on_request_error( self, mock_async_client_class: MagicMock, diff --git a/tests/amazon_creatorsapi/async_integration_test.py b/tests/amazon_creatorsapi/aio/integration_test.py similarity index 98% rename from tests/amazon_creatorsapi/async_integration_test.py rename to tests/amazon_creatorsapi/aio/integration_test.py index 20db78d..9c9cf2a 100644 --- a/tests/amazon_creatorsapi/async_integration_test.py +++ b/tests/amazon_creatorsapi/aio/integration_test.py @@ -11,7 +11,7 @@ from dotenv import load_dotenv -from amazon_creatorsapi import AsyncAmazonCreatorsApi +from amazon_creatorsapi.aio import AsyncAmazonCreatorsApi from amazon_creatorsapi.errors import ItemsNotFoundError if TYPE_CHECKING: @@ -314,9 +314,8 @@ async def test_get_variations_returns_variation_summary(self) -> None: if self.variations_result: summary = self.variations_result.variation_summary self.assertIsNotNone(summary) - if summary: - self.assertIsNotNone(summary.variation_count) - self.assertGreater(summary.variation_count, 0) # type: ignore[arg-type] + if summary and summary.variation_count is not None: + self.assertGreater(summary.variation_count, 0) else: self.skipTest(self.NO_VARIATIONS_FOUND_MSG) diff --git a/tests/amazon_creatorsapi/async_client_test.py b/tests/amazon_creatorsapi/async_client_test.py deleted file mode 100644 index 6bcc5ff..0000000 --- a/tests/amazon_creatorsapi/async_client_test.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Unit tests for async HTTP client.""" - -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -from amazon_creatorsapi.core.async_client import ( - DEFAULT_HOST, - DEFAULT_TIMEOUT, - USER_AGENT, - AsyncHttpClient, - AsyncHttpResponse, -) - - -class TestAsyncHttpResponse(unittest.TestCase): - """Tests for AsyncHttpResponse dataclass.""" - - def test_json_parsing(self) -> None: - """Test that json() correctly parses response body.""" - response = AsyncHttpResponse( - status_code=200, - headers={"content-type": "application/json"}, - body=b'{"key": "value"}', - text='{"key": "value"}', - ) - - result = response.json() - - self.assertEqual(result, {"key": "value"}) - - def test_attributes(self) -> None: - """Test that all attributes are correctly set.""" - response = AsyncHttpResponse( - status_code=404, - headers={"x-custom": "header"}, - body=b"test body", - text="test body", - ) - - self.assertEqual(response.status_code, 404) - self.assertEqual(response.headers, {"x-custom": "header"}) - self.assertEqual(response.body, b"test body") - self.assertEqual(response.text, "test body") - - -class TestAsyncHttpClientInit(unittest.TestCase): - """Tests for AsyncHttpClient initialization.""" - - def test_default_values(self) -> None: - """Test that default values are correctly set.""" - client = AsyncHttpClient() - - self.assertEqual(client._host, DEFAULT_HOST) - self.assertEqual(client._timeout, DEFAULT_TIMEOUT) - self.assertIsNone(client._client) - self.assertFalse(client._owns_client) - - def test_custom_values(self) -> None: - """Test that custom values are correctly set.""" - client = AsyncHttpClient( - host="https://custom.host", - timeout=60.0, - ) - - self.assertEqual(client._host, "https://custom.host") - self.assertEqual(client._timeout, 60.0) - - -class TestAsyncHttpClientContextManager(unittest.IsolatedAsyncioTestCase): - """Tests for AsyncHttpClient async context manager.""" - - @patch("amazon_creatorsapi.core.async_client.httpx.AsyncClient") - async def test_aenter_creates_client( - self, - mock_async_client_class: MagicMock, - ) -> None: - """Test that __aenter__ creates an httpx.AsyncClient.""" - mock_client = AsyncMock() - mock_async_client_class.return_value = mock_client - - client = AsyncHttpClient() - - result = await client.__aenter__() - - mock_async_client_class.assert_called_once() - self.assertIs(result, client) - self.assertTrue(client._owns_client) - - @patch("amazon_creatorsapi.core.async_client.httpx.AsyncClient") - async def test_aexit_closes_client( - self, - mock_async_client_class: MagicMock, - ) -> None: - """Test that __aexit__ closes the client.""" - mock_client = AsyncMock() - mock_async_client_class.return_value = mock_client - - client = AsyncHttpClient() - await client.__aenter__() - await client.__aexit__(None, None, None) - - mock_client.aclose.assert_called_once() - self.assertIsNone(client._client) - self.assertFalse(client._owns_client) - - -class TestAsyncHttpClientPost(unittest.IsolatedAsyncioTestCase): - """Tests for AsyncHttpClient.post() method.""" - - @patch("amazon_creatorsapi.core.async_client.httpx.AsyncClient") - async def test_post_with_context_manager( - self, - mock_async_client_class: MagicMock, - ) -> None: - """Test POST request using context manager (persistent client).""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} - mock_response.content = b'{"result": "ok"}' - mock_response.text = '{"result": "ok"}' - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - mock_async_client_class.return_value = mock_client - - async with AsyncHttpClient() as client: - response = await client.post( - "/test/path", - {"Authorization": "Bearer token"}, - {"data": "value"}, - ) - - mock_client.post.assert_called_once() - call_args = mock_client.post.call_args - self.assertEqual(call_args[0][0], "/test/path") - self.assertIn("Authorization", call_args[1]["headers"]) - self.assertIn(USER_AGENT, call_args[1]["headers"]["User-Agent"]) - self.assertEqual(call_args[1]["json"], {"data": "value"}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"result": "ok"}) - - @patch("amazon_creatorsapi.core.async_client.httpx.AsyncClient") - async def test_post_without_context_manager( - self, - mock_async_client_class: MagicMock, - ) -> None: - """Test POST request without context manager (creates new client).""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} - mock_response.content = b'{"standalone": true}' - mock_response.text = '{"standalone": true}' - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - mock_client.__aenter__.return_value = mock_client - mock_async_client_class.return_value = mock_client - - client = AsyncHttpClient() - response = await client.post("/test", {}, {"query": "test"}) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"standalone": True}) - - -if __name__ == "__main__": - unittest.main() From df8c5a0c4c6f68a81af46486fda5b9b42d9222cc Mon Sep 17 00:00:00 2001 From: Sergio Abad Date: Wed, 4 Feb 2026 23:18:07 +0100 Subject: [PATCH 3/5] feat: Add comprehensive unit tests for `AsyncHttpClient` and enhance API method tests to include resource handling. --- amazon_creatorsapi/aio/__init__.py | 2 +- tests/amazon_creatorsapi/aio/api_test.py | 193 ++++++++++++++++++++ tests/amazon_creatorsapi/aio/client_test.py | 159 ++++++++++++++++ 3 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 tests/amazon_creatorsapi/aio/client_test.py diff --git a/amazon_creatorsapi/aio/__init__.py b/amazon_creatorsapi/aio/__init__.py index cfc69d4..3963707 100644 --- a/amazon_creatorsapi/aio/__init__.py +++ b/amazon_creatorsapi/aio/__init__.py @@ -2,7 +2,7 @@ try: import httpx # noqa: F401 -except ImportError as exc: +except ImportError as exc: # pragma: no cover msg = ( "httpx is required for async support. " "Install with: pip install python-amazon-paapi[async]" diff --git a/tests/amazon_creatorsapi/aio/api_test.py b/tests/amazon_creatorsapi/aio/api_test.py index fc6025a..72ce801 100644 --- a/tests/amazon_creatorsapi/aio/api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -14,6 +14,12 @@ TooManyRequestsError, ) from creatorsapi_python_sdk.models.condition import Condition +from creatorsapi_python_sdk.models.get_browse_nodes_resource import ( + GetBrowseNodesResource, +) +from creatorsapi_python_sdk.models.get_items_resource import GetItemsResource +from creatorsapi_python_sdk.models.get_variations_resource import GetVariationsResource +from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource from creatorsapi_python_sdk.models.sort_by import SortBy @@ -120,6 +126,22 @@ async def test_context_manager_creates_and_closes_client( mock_client.__aexit__.assert_called_once() + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + async def test_context_manager_exit_without_client( + self, + mock_token_manager: MagicMock, + ) -> None: + """Test __aexit__ works explicitly when no client initialized.""" + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) + # Should not raise + await api.__aexit__(None, None, None) + class TestAsyncAmazonCreatorsApiGetItems(unittest.IsolatedAsyncioTestCase): """Tests for get_items() method.""" @@ -166,6 +188,45 @@ async def test_get_items_success( self.assertEqual(len(items), 1) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_items_with_resources( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_items with explicit resources.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) as api: + items = await api.get_items( + ["B0DLFMFBJW"], resources=[GetItemsResource.ITEM_INFO_DOT_TITLE] + ) + + self.assertEqual(len(items), 1) + # Verify resources were passed + call_args = mock_client.post.call_args + self.assertIn("'resources': ['itemInfo.title']", str(call_args)) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_items_not_found( @@ -280,6 +341,72 @@ async def test_search_items_success( self.assertIsNotNone(result) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_search_items_with_resources( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test search_items with explicit resources.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "searchResult": {"items": [{"ASIN": "B0DLFMFBJY"}]} + } + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) as api: + await api.search_items( + keywords="test", resources=[SearchItemsResource.ITEM_INFO_DOT_TITLE] + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_search_items_without_keywords( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test search_items without keywords (using other params).""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "searchResult": {"items": [{"ASIN": "B0DLFMFBJY"}]} + } + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) as api: + await api.search_items(browse_node_id="123456") + + call_args = mock_client.post.call_args + self.assertNotIn("keywords", str(call_args)) + self.assertIn("browseNodeId", str(call_args)) + class TestAsyncAmazonCreatorsApiErrorHandling(unittest.IsolatedAsyncioTestCase): """Tests for error handling.""" @@ -467,6 +594,38 @@ async def test_get_variations_success( self.assertIsNotNone(result) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_variations_with_resources( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_variations with explicit resources.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "variationsResult": {"items": [{"ASIN": "B0DLFMFBJV"}]} + } + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) as api: + await api.get_variations( + "B0DLFMFBJV", resources=[GetVariationsResource.ITEM_INFO_DOT_TITLE] + ) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_variations_with_params( @@ -585,6 +744,40 @@ async def test_get_browse_nodes_success( self.assertEqual(len(result), 1) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_browse_nodes_with_resources( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_browse_nodes with explicit resources.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "browseNodesResult": { + "browseNodes": [{"Id": "123456", "DisplayName": "Electronics"}] + } + } + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) as api: + await api.get_browse_nodes( + ["123456"], resources=[GetBrowseNodesResource.BROWSE_NODES_DOT_ANCESTOR] + ) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_get_browse_nodes_with_languages( diff --git a/tests/amazon_creatorsapi/aio/client_test.py b/tests/amazon_creatorsapi/aio/client_test.py new file mode 100644 index 0000000..3c8a605 --- /dev/null +++ b/tests/amazon_creatorsapi/aio/client_test.py @@ -0,0 +1,159 @@ +"""Unit tests for AsyncHttpClient.""" + +import subprocess +import sys +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from amazon_creatorsapi.aio.client import ( + DEFAULT_HOST, + DEFAULT_TIMEOUT, + AsyncHttpClient, + AsyncHttpResponse, +) + + +class TestAsyncHttpClient(unittest.IsolatedAsyncioTestCase): + """Tests for AsyncHttpClient.""" + + async def test_init_defaults(self) -> None: + """Test initialization with default values.""" + client = AsyncHttpClient() + self.assertEqual(client._host, DEFAULT_HOST) + self.assertEqual(client._timeout, DEFAULT_TIMEOUT) + self.assertIsNone(client._client) + self.assertFalse(client._owns_client) + + async def test_init_custom(self) -> None: + """Test initialization with custom values.""" + host = "https://custom.host" + timeout = 60.0 + client = AsyncHttpClient(host=host, timeout=timeout) + self.assertEqual(client._host, host) + self.assertEqual(client._timeout, timeout) + + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_context_manager(self, mock_client_cls: MagicMock) -> None: + """Test context manager creates and closes client.""" + mock_client_instance = AsyncMock() + mock_client_cls.return_value = mock_client_instance + + async with AsyncHttpClient() as client: + self.assertTrue(client._owns_client) + self.assertEqual(client._client, mock_client_instance) + mock_client_cls.assert_called_once() + + mock_client_instance.aclose.assert_called_once() + self.assertFalse(client._owns_client) + self.assertIsNone(client._client) + + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_info_logging_context_manager( + self, mock_client_cls: MagicMock + ) -> None: + """Test __aexit__ does nothing if client is None or not owned.""" + client = AsyncHttpClient() + await client.__aexit__(None, None, None) + # Should not raise + + client._owns_client = True + await client.__aexit__(None, None, None) + # client is None, so nothing happens + + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_post_without_context_manager( + self, mock_client_cls: MagicMock + ) -> None: + """Test post request creates temporary client.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.content = b'{"key": "value"}' + mock_response.text = '{"key": "value"}' + + mock_client_instance = AsyncMock() + mock_client_instance.post.return_value = mock_response + mock_client_instance.__aenter__.return_value = mock_client_instance + mock_client_instance.__aexit__.return_value = None + mock_client_cls.return_value = mock_client_instance + + client = AsyncHttpClient() + response = await client.post("/test", {}, {}) + + self.assertIsInstance(response, AsyncHttpResponse) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"key": "value"}) + + # Verify temporary client usage + mock_client_cls.assert_called() + mock_client_instance.__aenter__.assert_called() + mock_client_instance.__aexit__.assert_called() + + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_post_with_context_manager(self, mock_client_cls: MagicMock) -> None: + """Test post request reuses existing client.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.content = b"{}" + mock_response.text = "{}" + + mock_client_instance = AsyncMock() + mock_client_instance.post.return_value = mock_response + mock_client_cls.return_value = mock_client_instance + + async with AsyncHttpClient() as client: + mock_client_cls.reset_mock() # Reset call from __aenter__ + await client.post("/test", {}, {}) + + # Should NOT create new client + mock_client_cls.assert_not_called() + # Should call post on existing instance + mock_client_instance.post.assert_called_once() + + +class TestAsyncHttpResponse(unittest.TestCase): + """Tests for AsyncHttpResponse.""" + + def test_json_parsing(self) -> None: + """Test json parsing method.""" + response = AsyncHttpResponse( + status_code=200, headers={}, body=b'{"foo": "bar"}', text='{"foo": "bar"}' + ) + self.assertEqual(response.json(), {"foo": "bar"}) + + +class TestAsyncModuleInit(unittest.TestCase): + """Test async module initialization logic.""" + + def test_httpx_import_error(self) -> None: + """Test ImportError raised when httpx missing.""" + + code = """ +import sys +# Mock httpx as None in sys.modules to simulate it being missing +sys.modules['httpx'] = None +try: + import amazon_creatorsapi.aio +except ImportError as e: + if "httpx is required" in str(e): + sys.exit(0) + print(f"Wrong error message: {e}") + sys.exit(1) +except Exception as e: + print(f"Wrong exception type: {type(e)}") + sys.exit(1) +else: + print("No exception raised") + sys.exit(1) +""" + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", code], + capture_output=True, + text=True, + cwd=str(sys.path[0]), # Ensure we can import the package + check=False, + ) + self.assertEqual( + result.returncode, 0, f"Subprocess failed: {result.stdout} {result.stderr}" + ) From 8d7ac24794cae3413de4446a91ecce7b8df5a499 Mon Sep 17 00:00:00 2001 From: Sergio Abad Date: Thu, 5 Feb 2026 23:57:13 +0100 Subject: [PATCH 4/5] ci: Add `--extra async` to `uv run` command in the test workflow. --- .github/workflows/check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7c69b8e..a9e5f5d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -91,4 +91,4 @@ jobs: ${{ runner.os }}-uv-py${{ matrix.python-version }}- - name: Run tests - run: uv run --python "${{ matrix.python-version }}" pytest -rs --no-cov + run: uv run --python "${{ matrix.python-version }}" --extra async pytest -rs --no-cov From 1fc1b2fe19363aab34668d13655084ec0cb28e80 Mon Sep 17 00:00:00 2001 From: Sergio Abad Date: Thu, 5 Feb 2026 23:59:31 +0100 Subject: [PATCH 5/5] fix: Replace `assert` statements with `AuthenticationError` raises for unexpected `None` access tokens. --- amazon_creatorsapi/aio/auth.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/amazon_creatorsapi/aio/auth.py b/amazon_creatorsapi/aio/auth.py index 2b3138a..4907393 100644 --- a/amazon_creatorsapi/aio/auth.py +++ b/amazon_creatorsapi/aio/auth.py @@ -109,14 +109,18 @@ async def get_token(self) -> str: """ if self.is_token_valid(): # Token is cached and still valid, guaranteed to be str here - assert self._access_token is not None # noqa: S101 + if self._access_token is None: + msg = "Token should be valid at this point" + raise AuthenticationError(msg) return self._access_token # Need to refresh - use lock to prevent concurrent refreshes async with self._lock: # Double-check after acquiring lock if self.is_token_valid(): - assert self._access_token is not None # noqa: S101 + if self._access_token is None: + msg = "Token should be valid at this point" + raise AuthenticationError(msg) return self._access_token return await self.refresh_token()