diff --git a/CHANGELOG.md b/CHANGELOG.md index b40477b..a4a707a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `get_asin` and `errors` are available directly in `amazon_creatorsapi` - The identifier that Amazon gives to a request is part of the message of the error, so it can be reported to Amazon support - `py.typed` marker, so the type hints of the package are used by type checkers +- `close` method and context manager support in `AmazonCreatorsApi`, to release the connections of a client that is not going to be reused ### Changed @@ -21,11 +22,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AsyncAmazonCreatorsApi` builds its requests with the models of the SDK, so both clients validate the same values before sending a request - Every client uses its own configuration for the SDK instead of the one shared by the whole process - Throttling is measured with a monotonic clock and is safe to use from several threads +- `throttling` is validated like the rest of the options, so a negative or invalid value raises `InvalidArgumentError` instead of being accepted or failing with a `TypeError` +- `Retry-After` is also honoured when Amazon sends it as a date instead of an amount of seconds +- Both clients resolve the auth endpoint with the same list of versions, so a new version only has to be added once ### Fixed - Examples in the documentation that used names that do not exist, such as `SortBy.PRICE_LOW_TO_HIGH` or `GetItemsResource.ITEMINFO_TITLE` - Documented limits of `item_count`, `min_reviews_rating` and `variation_page`, which did not match the ones accepted by the API +- `auth_endpoint` is enough to use a version that the library does not know about in `AsyncAmazonCreatorsApi`, which rejected it even with a custom endpoint +- A token response that does not hold JSON raises `AuthenticationError` in `AsyncAmazonCreatorsApi`, instead of the error of the JSON parser +- Errors reported by the transport, such as an invalid certificate, keep their reason instead of being reported as `Request failed with status 0` +- An ASIN longer than ten characters in a URL is rejected instead of being trimmed to a different item +- `get_items` raises `ItemsNotFoundError` when the response holds no requested item, instead of returning an empty list +- Threads sharing a client ask for a single token when the cached one expires, instead of one for every thread ### Removed diff --git a/README.md b/README.md index a708a2e..50f19f3 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,15 @@ amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, throttling=4) # M amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, throttling=0) # No wait time between requests ``` +### Closing the client + +The client keeps a pool of connections open, so it is meant to be created once and reused. Close it, or use it as a context manager, when it is not going to be used again: + +```python +with AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY) as amazon: + items = amazon.get_items(["B01N5IB20Q"]) +``` + ### Timeout Timeout value represents the number of seconds to wait for a response before failing, being the default value 30 seconds. Use `None` to wait indefinitely. @@ -196,7 +205,7 @@ amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, retries=0) # Fail ### Custom Endpoints -The base URL of the API and the one used to get the OAuth2 token can be replaced, which is useful to run the tests of a project against a mock server: +The base URL of the API and the one used to get the OAuth2 token can be replaced, which is useful to run the tests of a project against a mock server. Providing `auth_endpoint` also makes any `version` valid, so a new one can be used before the library knows about it: ```python api = AmazonCreatorsApi( diff --git a/amazon_creatorsapi/aio/api.py b/amazon_creatorsapi/aio/api.py index 097dd9e..ce74ead 100644 --- a/amazon_creatorsapi/aio/api.py +++ b/amazon_creatorsapi/aio/api.py @@ -25,6 +25,7 @@ get_unique_items, sort_items, ) +from amazon_creatorsapi.core.oauth import get_auth_endpoint from amazon_creatorsapi.core.parsers import get_asin, get_items_ids from amazon_creatorsapi.core.requests import get_request_body from amazon_creatorsapi.core.resources import get_all_resources @@ -35,6 +36,7 @@ validate_and_get_marketplace, validate_retries, validate_search_criteria, + validate_throttling, validate_timeout, ) from amazon_creatorsapi.errors import ( @@ -48,7 +50,7 @@ try: import httpx - from .auth import VERSION_ENDPOINTS, AsyncOAuth2TokenManager + from .auth import AsyncOAuth2TokenManager from .client import AsyncHttpClient, AsyncHttpResponse except ImportError as exc: # pragma: no cover msg = ( @@ -176,9 +178,10 @@ class AsyncAmazonCreatorsApi: Raises: InvalidArgumentError: If neither country nor marketplace is provided, - if timeout is not greater than zero, or if retries is negative. - ValueError: If version is not supported (valid versions: 2.1, 2.2, 2.3, - 3.1, 3.2, 3.3). + if timeout is not greater than zero, if throttling is negative or + if retries is negative. + ValueError: If the version is not supported and no auth_endpoint is + given (valid versions: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3). """ @@ -197,19 +200,20 @@ def __init__( auth_endpoint: str | None = None, ) -> None: """Initialize the async Amazon Creators API client.""" - # Validate version early to fail fast (before token manager initialization) - self._validate_version(version) + # Resolve the endpoint early to fail fast on an unsupported version, + # which a custom endpoint makes valid + endpoint = get_auth_endpoint(version, auth_endpoint) self._credential_id = credential_id self._credential_secret = credential_secret self._version = version - self._last_query_time = time.monotonic() - throttling self.host = host self._throttle_lock: asyncio.Lock | None = None self.tag = tag - self.throttling = float(throttling) + self.throttling = validate_throttling(throttling) self.timeout = validate_timeout(timeout) self.retries = validate_retries(retries) + self._last_query_time = time.monotonic() - self.throttling # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) @@ -220,26 +224,11 @@ def __init__( credential_id=credential_id, credential_secret=credential_secret, version=version, - auth_endpoint=auth_endpoint, + auth_endpoint=endpoint, timeout=self.timeout, ) self._owns_client = False - def _validate_version(self, version: str) -> None: - """Validate that the API version is supported. - - Args: - version: API version to validate. - - Raises: - ValueError: If version is not in the list of supported versions. - - """ - if version not in VERSION_ENDPOINTS: - supported = ", ".join(VERSION_ENDPOINTS.keys()) - msg = f"Unsupported version: {version}. Supported versions are: {supported}" - raise ValueError(msg) - async def __aenter__(self) -> Self: """Enter async context manager, creating a persistent HTTP client.""" self._http_client = AsyncHttpClient(host=self.host, timeout=self.timeout) @@ -273,7 +262,8 @@ async def get_items( Duplicated items are requested only once, and the request is split into as many API calls as needed when it goes over the limit of items that - Amazon accepts at once. + Amazon accepts at once. A call that keeps failing after the retries + raises, discarding the items returned by the previous calls. Args: items: One or more items, using ASIN or Amazon product URL. @@ -329,14 +319,17 @@ async def get_items( if items_result.get("items"): found_items.extend(self._deserialize_items(items_result["items"])) - if not found_items and not include_unavailable: + sorted_items = sort_items( + found_items, + item_ids, + include_unavailable=include_unavailable, + ) + + if not sorted_items and not include_unavailable: msg = f"No items have been found{format_errors(errors)}" raise ItemsNotFoundError(msg) - return ResultList( - sort_items(found_items, item_ids, include_unavailable=include_unavailable), - errors=errors, - ) + return ResultList(sorted_items, errors=errors) async def search_items( self, diff --git a/amazon_creatorsapi/aio/auth.py b/amazon_creatorsapi/aio/auth.py index c7c6f39..a38da26 100644 --- a/amazon_creatorsapi/aio/auth.py +++ b/amazon_creatorsapi/aio/auth.py @@ -7,8 +7,20 @@ import asyncio import time - -from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT +from typing import Any + +from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT, HTTP_OK +from amazon_creatorsapi.core.oauth import ( + COGNITO_SCOPE, + DEFAULT_EXPIRATION, + GRANT_TYPE, + LWA_SCOPE, + TOKEN_EXPIRATION_BUFFER, + VERSION_ENDPOINTS, + get_auth_endpoint, + get_scope, + is_lwa, +) from amazon_creatorsapi.errors import AuthenticationError try: @@ -21,25 +33,18 @@ raise ImportError(msg) from exc -# OAuth2 constants -COGNITO_SCOPE = "creatorsapi/default" -LWA_SCOPE = "creatorsapi::default" # Backward-compatible alias for existing v2.x users. SCOPE = COGNITO_SCOPE -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", - "3.1": "https://api.amazon.com/auth/o2/token", - "3.2": "https://api.amazon.co.uk/auth/o2/token", - "3.3": "https://api.amazon.co.jp/auth/o2/token", -} +__all__ = [ + "COGNITO_SCOPE", + "GRANT_TYPE", + "LWA_SCOPE", + "SCOPE", + "TOKEN_EXPIRATION_BUFFER", + "VERSION_ENDPOINTS", + "AsyncOAuth2TokenManager", +] class AsyncOAuth2TokenManager: @@ -98,23 +103,15 @@ def _determine_auth_endpoint( 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] + return get_auth_endpoint(version, auth_endpoint) def is_lwa(self) -> bool: """Return whether this token manager uses the LWA auth flow.""" - return self._version.startswith("3.") + return is_lwa(self._version) def get_scope(self) -> str: """Return the version-appropriate OAuth2 scope.""" - return LWA_SCOPE if self.is_lwa() else COGNITO_SCOPE + return get_scope(self._version) @property def lock(self) -> asyncio.Lock: @@ -205,7 +202,7 @@ async def refresh_token(self) -> str: headers={"Content-Type": "application/x-www-form-urlencoded"}, ) - if response.status_code != 200: # noqa: PLR2004 + if response.status_code != HTTP_OK: self.clear_token() msg = ( f"OAuth2 token request failed with status {response.status_code}: " @@ -213,7 +210,7 @@ async def refresh_token(self) -> str: ) raise AuthenticationError(msg) - data = response.json() + data = self._parse_token_response(response) if "access_token" not in data: self.clear_token() @@ -222,7 +219,7 @@ async def refresh_token(self) -> str: self._access_token = data["access_token"] # Set expiration time with buffer to avoid edge cases - expires_in = data.get("expires_in", 3600) + expires_in = data.get("expires_in", DEFAULT_EXPIRATION) self._expires_at = time.time() + expires_in - TOKEN_EXPIRATION_BUFFER except httpx.RequestError as exc: @@ -236,6 +233,27 @@ async def refresh_token(self) -> str: raise AuthenticationError(msg) return self._access_token + def _parse_token_response(self, response: httpx.Response) -> dict[str, Any]: + """Parse the token response as JSON. + + Args: + response: Response from the auth endpoint. + + Returns: + The parsed response body. + + Raises: + AuthenticationError: If the response is not valid JSON. + + """ + try: + data: dict[str, Any] = response.json() + except ValueError as error: + self.clear_token() + msg = f"Failed to parse OAuth2 token response: {error}" + raise AuthenticationError(msg) from error + return data + def clear_token(self) -> None: """Clear the cached token, forcing a refresh on the next get_token() call.""" self._access_token = None diff --git a/amazon_creatorsapi/api.py b/amazon_creatorsapi/api.py index 1906493..d11ccdb 100644 --- a/amazon_creatorsapi/api.py +++ b/amazon_creatorsapi/api.py @@ -24,6 +24,7 @@ get_unique_items, sort_items, ) +from amazon_creatorsapi.core.oauth import get_auth_endpoint from amazon_creatorsapi.core.parsers import get_asin, get_items_ids from amazon_creatorsapi.core.resources import get_all_resources from amazon_creatorsapi.core.results import ResultList @@ -33,6 +34,7 @@ validate_and_get_marketplace, validate_retries, validate_search_criteria, + validate_throttling, validate_timeout, ) from amazon_creatorsapi.errors import ( @@ -73,6 +75,8 @@ from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource if TYPE_CHECKING: + from types import TracebackType + from amazon_creatorsapi.core.marketplaces import CountryCode from creatorsapi_python_sdk.models.availability import Availability from creatorsapi_python_sdk.models.browse_node import BrowseNode @@ -89,6 +93,9 @@ from creatorsapi_python_sdk.models.variations_result import VariationsResult ResponseT = TypeVar("ResponseT") +# typing.Self needs Python 3.11 and typing_extensions is only required by the +# async extra, so the client is typed with a TypeVar bound to itself +ClientT = TypeVar("ClientT", bound="AmazonCreatorsApi") class AmazonCreatorsApi: @@ -112,7 +119,10 @@ class AmazonCreatorsApi: Raises: InvalidArgumentError: If neither country nor marketplace is provided, - if timeout is not greater than zero, or if retries is negative. + if timeout is not greater than zero, if throttling is negative or + if retries is negative. + ValueError: If the version is not supported and no auth_endpoint is + given (valid versions: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3). Example: >>> api = AmazonCreatorsApi( @@ -124,6 +134,10 @@ class AmazonCreatorsApi: ... ) >>> items = api.get_items(["B0DLFMFBJW"]) + The client keeps a pool of connections open, so it is meant to be reused. + Call close, or use it as a context manager, to release the connections of + a client that is not going to be used again. + """ def __init__( @@ -144,16 +158,20 @@ def __init__( self._credential_id = credential_id self._credential_secret = credential_secret self._version = version - self._last_query_time = time.monotonic() - throttling self._throttle_lock = threading.Lock() self.tag = tag - self.throttling = float(throttling) + self.throttling = validate_throttling(throttling) self.timeout = validate_timeout(timeout) self.retries = validate_retries(retries) + self._last_query_time = time.monotonic() - self.throttling # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) + # The endpoint is resolved here, so both clients share the same list + # of versions instead of relying on the one bundled with the SDK + endpoint = get_auth_endpoint(version, auth_endpoint) + # A new configuration for every client, as the default one of the # SDK is shared by the whole process self._api_client = ApiClient( @@ -162,17 +180,39 @@ def __init__( credential_secret=credential_secret, version=version, host=host, - auth_endpoint=auth_endpoint, + auth_endpoint=endpoint, ) # The token manager bundled with the SDK requests the token without # any timeout, so it is replaced by one that honours the configured # value and reports failures as library errors. self._api_client._token_manager = TimeoutOAuth2TokenManager( # noqa: SLF001 - OAuth2Config(credential_id, credential_secret, version, auth_endpoint), + OAuth2Config(credential_id, credential_secret, version, endpoint), self.timeout, ) self._api = DefaultApi(self._api_client) + def __enter__(self: ClientT) -> ClientT: # noqa: PYI019 + """Return the client, which closes its connections when leaving.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Close the connections of the client.""" + self.close() + + def close(self) -> None: + """Release the connections kept open by the client. + + The client stays usable after closing it, opening a new connection on + the next request. Calling it is only needed for clients that are not + reused, as the ones created for a single request. + """ + self._api_client.rest_client.pool_manager.clear() + def get_items( self, items: str | list[str], @@ -187,7 +227,8 @@ def get_items( Duplicated items are requested only once, and the request is split into as many API calls as needed when it goes over the limit of items that - Amazon accepts at once. + Amazon accepts at once. A call that keeps failing after the retries + raises, discarding the items returned by the previous calls. Args: items: One or more items, using ASIN or Amazon product URL. @@ -242,14 +283,17 @@ def get_items( if response.items_result is not None and response.items_result.items: found_items.extend(response.items_result.items) - if not found_items and not include_unavailable: + sorted_items = sort_items( + found_items, + item_ids, + include_unavailable=include_unavailable, + ) + + if not sorted_items and not include_unavailable: msg = f"No items have been found{format_errors(errors)}" raise ItemsNotFoundError(msg) - return ResultList( - sort_items(found_items, item_ids, include_unavailable=include_unavailable), - errors=errors, - ) + return ResultList(sorted_items, errors=errors) def search_items( self, @@ -645,9 +689,16 @@ def _handle_api_exception( ) -> NoReturn: """Handle API exceptions and raise appropriate custom exceptions.""" body = error.body if isinstance(error.body, str) else "" + reason = str(error.reason) if error.reason else None try: - handle_api_error(error.status, body, not_found_error, error.headers) + handle_api_error( + error.status, + body, + not_found_error, + error.headers, + reason, + ) except AmazonCreatorsApiError as exc: # Re-raise with original exception as cause for better stack traces raise exc from error diff --git a/amazon_creatorsapi/core/auth.py b/amazon_creatorsapi/core/auth.py index a43654c..6d4e51e 100644 --- a/amazon_creatorsapi/core/auth.py +++ b/amazon_creatorsapi/core/auth.py @@ -2,29 +2,29 @@ from __future__ import annotations +import threading import time from typing import TYPE_CHECKING, Any import requests +from amazon_creatorsapi.core.constants import HTTP_OK +from amazon_creatorsapi.core.oauth import DEFAULT_EXPIRATION, TOKEN_EXPIRATION_BUFFER from amazon_creatorsapi.errors import AuthenticationError from creatorsapi_python_sdk.auth.oauth2_token_manager import OAuth2TokenManager if TYPE_CHECKING: from creatorsapi_python_sdk.auth.oauth2_config import OAuth2Config -# Token expiration buffer in seconds (refresh before the actual expiration) -TOKEN_EXPIRATION_BUFFER = 30 -DEFAULT_EXPIRATION = 3600 -HTTP_OK = 200 - class TimeoutOAuth2TokenManager(OAuth2TokenManager): """Token manager that fails instead of waiting forever for a token. The token manager bundled with the SDK requests the token without any timeout, so an unresponsive auth endpoint blocks the call indefinitely - even when a timeout is set for the API requests themselves. + even when a timeout is set for the API requests themselves. It also asks + for a token without any lock, so every thread sharing a client requests + its own token as soon as the cached one expires. Args: config: OAuth2 configuration with the credentials and the endpoint. @@ -37,6 +37,25 @@ def __init__(self, config: OAuth2Config, timeout: float | None) -> None: """Initialize the token manager with its timeout.""" super().__init__(config) self._timeout = timeout + self._lock = threading.Lock() + + def get_token(self) -> str: + """Return a valid token, asking for a new one only once at a time. + + Returns: + A valid access token. + + Raises: + AuthenticationError: If the token cannot be obtained. + + """ + if self.is_token_valid(): + return str(self.access_token) + + with self._lock: + if self.is_token_valid(): + return str(self.access_token) + return self.refresh_token() def refresh_token(self) -> str: """Refresh the OAuth2 access token using the client credentials grant. diff --git a/amazon_creatorsapi/core/error_handling.py b/amazon_creatorsapi/core/error_handling.py index 51eff9d..aede1d2 100644 --- a/amazon_creatorsapi/core/error_handling.py +++ b/amazon_creatorsapi/core/error_handling.py @@ -113,6 +113,7 @@ def handle_api_error( body: str, not_found_error: type[AmazonCreatorsApiError] = ItemsNotFoundError, headers: Mapping[str, str] | None = None, + reason: str | None = None, ) -> NoReturn: """Handle API error responses and raise appropriate exceptions. @@ -123,6 +124,8 @@ def handle_api_error( operations tell apart items from feeds and reports. headers: Headers of the response, used to report the identifier that Amazon gave to the request. + reason: Reason of the failure, used when the response has no body, + as happens for the errors reported by the transport itself. Raises: InvalidArgumentError: For requests rejected by the API. @@ -135,16 +138,16 @@ def handle_api_error( """ data = parse_error_body(body) - detail = get_error_detail(data, body) - reason = data.get("reason") + detail = get_error_detail(data, body) or (f" - {reason}" if reason else "") + error_reason = data.get("reason") request_id = get_request_id(headers) if request_id: detail = f"{detail} [request id: {request_id}]" if status_code == HTTP_BAD_REQUEST: - if reason == INVALID_ASSOCIATE_REASON or ( - reason is None and INVALID_ASSOCIATE_REASON in body + if error_reason == INVALID_ASSOCIATE_REASON or ( + error_reason is None and INVALID_ASSOCIATE_REASON in body ): msg = f"Credentials are not valid for the selected marketplace{detail}" raise AssociateValidationError(msg) diff --git a/amazon_creatorsapi/core/oauth.py b/amazon_creatorsapi/core/oauth.py new file mode 100644 index 0000000..be99a57 --- /dev/null +++ b/amazon_creatorsapi/core/oauth.py @@ -0,0 +1,77 @@ +"""OAuth2 settings shared by the synchronous and the asynchronous clients.""" + +from __future__ import annotations + +# Scopes and grant type accepted by the auth endpoints of Amazon +COGNITO_SCOPE = "creatorsapi/default" +LWA_SCOPE = "creatorsapi::default" +GRANT_TYPE = "client_credentials" + +# Seconds subtracted from the lifetime of a token, so it is refreshed before +# the actual expiration +TOKEN_EXPIRATION_BUFFER = 30 + +# Lifetime assumed for a token when the auth endpoint does not send one +DEFAULT_EXPIRATION = 3600 + +# Auth endpoint of every version of the API, Cognito for 2.x and LWA for 3.x +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", + "3.1": "https://api.amazon.com/auth/o2/token", + "3.2": "https://api.amazon.co.uk/auth/o2/token", + "3.3": "https://api.amazon.co.jp/auth/o2/token", +} + + +def is_lwa(version: str) -> bool: + """Return whether a version authenticates with Login with Amazon. + + Args: + version: API version in use. + + Returns: + True for the versions using LWA, False for the ones using Cognito. + + """ + return version.startswith("3.") + + +def get_scope(version: str) -> str: + """Return the OAuth2 scope of a version. + + Args: + version: API version in use. + + Returns: + The scope to ask the auth endpoint for. + + """ + return LWA_SCOPE if is_lwa(version) else COGNITO_SCOPE + + +def get_auth_endpoint(version: str, auth_endpoint: str | None = None) -> str: + """Return the auth endpoint to use, validating the version when needed. + + Args: + version: API version in use. + auth_endpoint: Endpoint provided by the user, which takes precedence + over the one of the version and makes any version valid. + + Returns: + The URL used to get the OAuth2 token. + + Raises: + ValueError: If the version is not supported and no endpoint is given. + + """ + if auth_endpoint and auth_endpoint.strip(): + return auth_endpoint + + if version not in VERSION_ENDPOINTS: + supported = ", ".join(VERSION_ENDPOINTS) + msg = f"Unsupported version: {version}. Supported versions are: {supported}" + raise ValueError(msg) + + return VERSION_ENDPOINTS[version] diff --git a/amazon_creatorsapi/core/parsers.py b/amazon_creatorsapi/core/parsers.py index ac70b84..c840432 100644 --- a/amazon_creatorsapi/core/parsers.py +++ b/amazon_creatorsapi/core/parsers.py @@ -24,8 +24,13 @@ def get_asin(text: str) -> str: if re.search(r"^[a-zA-Z0-9]{10}$", text): return text.upper() - # Extract ASIN from URL searching for common Amazon URL patterns - asin = re.search(r"(dp|gp/product|gp/aw/d|dp/product)/([a-zA-Z0-9]{10})", text) + # Extract ASIN from URL searching for common Amazon URL patterns. The + # identifier has to end after ten characters, so a longer one is reported + # instead of silently trimmed to something that looks like a valid ASIN. + asin = re.search( + r"(dp|gp/product|gp/aw/d|dp/product)/([a-zA-Z0-9]{10})(?![a-zA-Z0-9])", + text, + ) if asin: return asin.group(2).upper() diff --git a/amazon_creatorsapi/core/retry.py b/amazon_creatorsapi/core/retry.py index 742db91..ee2ed2d 100644 --- a/amazon_creatorsapi/core/retry.py +++ b/amazon_creatorsapi/core/retry.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -41,7 +43,7 @@ def get_retry_after(headers: Mapping[str, str] | None) -> float | None: Returns: The amount of seconds to wait, or None when the header is missing or - does not hold an amount of seconds. + holds neither an amount of seconds nor a date. """ if not headers: @@ -53,11 +55,35 @@ def get_retry_after(headers: Mapping[str, str] | None) -> float | None: try: return max(float(value), 0.0) except (TypeError, ValueError): - return None + return get_seconds_until(value) return None +def get_seconds_until(value: str) -> float | None: + """Return the seconds left until an HTTP date, which Retry-After allows. + + Args: + value: Value of the header, expected to hold a date. + + Returns: + The amount of seconds until the date, zero when it is already past, + or None when the value is not a date. + + """ + # Python 3.9 reports an unparseable value with a TypeError instead of the + # ValueError raised by the newer versions + try: + date = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + + if date.tzinfo is None: + date = date.replace(tzinfo=timezone.utc) + + return max((date - datetime.now(timezone.utc)).total_seconds(), 0.0) + + def get_retry_delay(attempt: int, headers: Mapping[str, str] | None = None) -> float: """Return the seconds to wait before the next attempt. diff --git a/amazon_creatorsapi/core/validation.py b/amazon_creatorsapi/core/validation.py index f12d3c1..3aa591c 100644 --- a/amazon_creatorsapi/core/validation.py +++ b/amazon_creatorsapi/core/validation.py @@ -59,10 +59,39 @@ def validate_timeout(timeout: float | None) -> float | None: """ if timeout is None: return None - if timeout <= 0: + try: + value = float(timeout) + except (TypeError, ValueError) as error: + msg = f"Timeout must be a number of seconds, or None: {timeout!r}" + raise InvalidArgumentError(msg) from error + if value <= 0: msg = "Timeout must be greater than zero, or None to wait indefinitely" raise InvalidArgumentError(msg) - return float(timeout) + return value + + +def validate_throttling(throttling: float) -> float: + """Validate the wait time between API calls. + + Args: + throttling: Wait time in seconds between API calls. + + Returns: + The wait time as a float. + + Raises: + InvalidArgumentError: If the wait time is not a number or is negative. + + """ + try: + value = float(throttling) + except (TypeError, ValueError) as error: + msg = f"Throttling must be a number of seconds: {throttling!r}" + raise InvalidArgumentError(msg) from error + if value < 0: + msg = "Throttling must be zero or greater" + raise InvalidArgumentError(msg) + return value def build_request(request_class: type[RequestT], **fields: Any) -> RequestT: @@ -100,13 +129,19 @@ def validate_retries(retries: int) -> int: The amount of retries as an integer. Raises: - InvalidArgumentError: If the amount of retries is negative. + InvalidArgumentError: If the amount of retries is not a whole number + or is negative. """ - if retries < 0: + try: + value = int(retries) + except (TypeError, ValueError) as error: + msg = f"Retries must be a whole number: {retries!r}" + raise InvalidArgumentError(msg) from error + if value < 0: msg = "Retries must be zero or greater" raise InvalidArgumentError(msg) - return int(retries) + return value def validate_search_criteria(**criteria: object) -> None: diff --git a/pyproject.toml b/pyproject.toml index 022f632..9f508e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,9 @@ exclude = ["creatorsapi_python_sdk/*", "docs/*"] exclude = ["creatorsapi_python_sdk/*", "docs/*", ".github"] [tool.mypy] -python_version = "3.9" +# The package supports Python 3.9, but mypy only checks 3.10 and above, so the +# lowest version it can be told about is the one used here +python_version = "3.10" ignore_missing_imports = true no_implicit_optional = true strict_equality = true diff --git a/tests/amazon_creatorsapi/aio/api_test.py b/tests/amazon_creatorsapi/aio/api_test.py index a2b8adf..ffe3c5e 100644 --- a/tests/amazon_creatorsapi/aio/api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -12,6 +12,7 @@ ) from amazon_creatorsapi.aio.api import API_HOST from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT +from amazon_creatorsapi.core.oauth import VERSION_ENDPOINTS from amazon_creatorsapi.errors import ( AssociateValidationError, AuthenticationError, @@ -212,6 +213,73 @@ def test_raises_error_for_invalid_version( self.assertIn("Unsupported version: 9.9", str(context.exception)) self.assertIn("Supported versions are:", str(context.exception)) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_custom_endpoint_accepts_any_version( + self, mock_token_manager: MagicMock + ) -> None: + """Test that a custom endpoint makes any version valid.""" + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="9.9", + tag="test-tag", + country="ES", + auth_endpoint="https://example.com/token", + ) + + self.assertEqual( + mock_token_manager.call_args.kwargs["auth_endpoint"], + "https://example.com/token", + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_the_endpoint_of_the_version_is_used( + self, mock_token_manager: MagicMock + ) -> None: + """Test that the endpoint of the version reaches the token manager.""" + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) + + self.assertEqual( + mock_token_manager.call_args.kwargs["auth_endpoint"], + VERSION_ENDPOINTS["2.2"], + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_negative_throttling_is_rejected( + self, mock_token_manager: MagicMock + ) -> None: + """Test that a negative wait time between calls is rejected.""" + with self.assertRaises(InvalidArgumentError): + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=-1, + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_throttling_that_is_not_a_number_is_rejected( + self, mock_token_manager: MagicMock + ) -> None: + """Test that a wait time that is not a number is rejected.""" + with self.assertRaises(InvalidArgumentError): + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling="fast", # type: ignore[arg-type] + ) + class TestAsyncAmazonCreatorsApiContextManager(unittest.IsolatedAsyncioTestCase): """Tests for AsyncAmazonCreatorsApi async context manager.""" @@ -713,8 +781,8 @@ async def test_throttling_waits_between_requests( country="ES", throttling=0.5, ) as api: - await api.get_items(["B0DLFMFBJ1"]) - await api.get_items(["B0DLFMFBJ2"]) + await api.get_items(["B0DLFMFBJZ"]) + await api.get_items(["B0DLFMFBJZ"]) # asyncio.sleep should have been called for throttling self.assertTrue(mock_sleep.called) @@ -2422,5 +2490,46 @@ async def test_custom_host_and_auth_endpoint( ) +class TestAsyncAmazonCreatorsApiUnrequestedItems(unittest.IsolatedAsyncioTestCase): + """Tests for a response holding items that were not requested.""" + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_items_of_other_asins_are_not_found( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that a response without any requested item is not found.""" + response = MagicMock() + response.status_code = 200 + response.headers = {} + response.json.return_value = { + "itemsResult": {"items": [{"asin": "B000000002"}]} + } + + mock_client = AsyncMock() + mock_client.post.return_value = 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, + retries=0, + ) + + with self.assertRaises(ItemsNotFoundError): + await api.get_items(["B000000001"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/amazon_creatorsapi/aio/auth_test.py b/tests/amazon_creatorsapi/aio/auth_test.py index a46e503..d437ac0 100644 --- a/tests/amazon_creatorsapi/aio/auth_test.py +++ b/tests/amazon_creatorsapi/aio/auth_test.py @@ -354,6 +354,29 @@ async def test_raises_error_on_request_error( self.assertIn("token request failed", str(context.exception)) +class TestAsyncOAuth2TokenManagerInvalidJson(unittest.IsolatedAsyncioTestCase): + """Tests for a token response that does not hold JSON.""" + + async def test_invalid_json_raises_authentication_error(self) -> None: + """Test that an unparseable response is reported as a library error.""" + response = MagicMock() + response.status_code = 200 + response.json.side_effect = ValueError("Expecting value") + + client = AsyncMock() + client.post.return_value = response + client.__aenter__.return_value = client + + manager = AsyncOAuth2TokenManager("test_id", "test_secret", "2.2") + + with patch("httpx.AsyncClient", return_value=client): + with self.assertRaises(AuthenticationError) as context: + await manager.refresh_token() + + self.assertIn("Failed to parse OAuth2 token response", str(context.exception)) + self.assertIsNone(manager._access_token) + + class TestAsyncOAuth2TokenManagerTimeout(unittest.IsolatedAsyncioTestCase): """Tests for the timeout applied to token refresh requests.""" diff --git a/tests/amazon_creatorsapi/api_test.py b/tests/amazon_creatorsapi/api_test.py index a80acbc..edebf49 100644 --- a/tests/amazon_creatorsapi/api_test.py +++ b/tests/amazon_creatorsapi/api_test.py @@ -13,6 +13,7 @@ from amazon_creatorsapi import AmazonCreatorsApi from amazon_creatorsapi.core.auth import TimeoutOAuth2TokenManager from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT +from amazon_creatorsapi.core.oauth import VERSION_ENDPOINTS from amazon_creatorsapi.errors import ( AccessDeniedError, AssociateValidationError, @@ -165,7 +166,7 @@ def test_get_items( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -774,7 +775,7 @@ def test_get_items_with_explicit_resources( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -1107,7 +1108,7 @@ def test_get_items_uses_default_timeout( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -1138,7 +1139,7 @@ def test_get_items_forwards_custom_timeout( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -1169,7 +1170,7 @@ def test_get_items_with_timeout_disabled( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -1859,3 +1860,116 @@ def test_every_client_has_its_own_configuration(self) -> None: first._api_client.configuration, second._api_client.configuration, ) + + def build_api_with(self, **options: object) -> AmazonCreatorsApi: + """Build an API client overriding any of its options.""" + return AmazonCreatorsApi( + **{ # type: ignore[arg-type] + "credential_id": self.credential_id, + "credential_secret": self.credential_secret, + "version": self.version, + "tag": self.tag, + "country": self.country, + "throttling": 0, + "retries": 0, + **options, + } + ) + + def test_unsupported_version_is_rejected(self) -> None: + """Test that a version out of the list needs a custom endpoint.""" + with self.assertRaises(ValueError) as context: + self.build_api_with(version="4.0") + + self.assertIn("Unsupported version: 4.0", str(context.exception)) + + def test_custom_endpoint_accepts_any_version(self) -> None: + """Test that a custom endpoint makes any version valid.""" + api = self.build_api_with( + version="4.0", + auth_endpoint="https://example.com/token", + ) + + token_manager = api._api_client.token_manager + assert token_manager is not None + self.assertEqual( + token_manager.config.get_cognito_endpoint(), + "https://example.com/token", + ) + + def test_the_endpoint_of_the_version_is_used(self) -> None: + """Test that the endpoint of the version reaches the token manager.""" + api = self.build_api() + + token_manager = api._api_client.token_manager + assert token_manager is not None + self.assertEqual( + token_manager.config.get_cognito_endpoint(), + VERSION_ENDPOINTS["2.2"], + ) + + def test_negative_throttling_is_rejected(self) -> None: + """Test that a negative wait time between calls is rejected.""" + with self.assertRaises(InvalidArgumentError): + self.build_api_with(throttling=-1) + + def test_throttling_that_is_not_a_number_is_rejected(self) -> None: + """Test that a wait time that is not a number is rejected.""" + with self.assertRaises(InvalidArgumentError): + self.build_api_with(throttling="fast") + + def test_close_releases_the_connections(self) -> None: + """Test that closing the client clears the pool of connections.""" + api = self.build_api() + api._api_client.rest_client.pool_manager = MagicMock() + + api.close() + + api._api_client.rest_client.pool_manager.clear.assert_called_once() + + def test_context_manager_closes_the_client(self) -> None: + """Test that leaving the context manager closes the client.""" + api = self.build_api() + api._api_client.rest_client.pool_manager = MagicMock() + + with api as client: + self.assertIs(client, api) + + api._api_client.rest_client.pool_manager.clear.assert_called_once() + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_transport_error_keeps_its_reason( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that an error without a body is reported with its reason.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = ApiException( + status=0, + reason="SSL error: certificate verify failed", + ) + + with self.assertRaises(RequestError) as context: + self.build_api().get_items(["B000000001"]) + + self.assertIn("certificate verify failed", str(context.exception)) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_items_of_other_asins_are_not_found( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that a response without any requested item is not found.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.return_value = GetItemsResponseContent( + itemsResult=ItemsResult(items=[Item(asin="B000000002")]), + ) + + with self.assertRaises(ItemsNotFoundError): + self.build_api().get_items(["B000000001"]) diff --git a/tests/amazon_creatorsapi/core/auth_test.py b/tests/amazon_creatorsapi/core/auth_test.py index 2483a40..35f7d1c 100644 --- a/tests/amazon_creatorsapi/core/auth_test.py +++ b/tests/amazon_creatorsapi/core/auth_test.py @@ -2,6 +2,8 @@ from __future__ import annotations +import threading +import time import unittest from unittest import mock from unittest.mock import MagicMock @@ -119,3 +121,45 @@ def test_invalid_json_raises_authentication_error( self.manager.refresh_token() self.assertIn("parse OAuth2 token response", str(context.exception)) + + +class TestTimeoutOAuth2TokenManagerThreads(unittest.TestCase): + """Tests for the token manager when it is shared by several threads.""" + + @mock.patch("amazon_creatorsapi.core.auth.requests.post") + def test_only_one_thread_requests_the_token(self, mock_post: MagicMock) -> None: + """Test that threads asking at once share a single token request.""" + started = threading.Barrier(4) + + def build_token(*_args: object, **_kwargs: object) -> MagicMock: + # The request takes long enough for the other threads to reach the + # cache while it is still empty + time.sleep(0.05) + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "access_token": "test_token", + "expires_in": 3600, + } + return response + + mock_post.side_effect = build_token + + manager = TimeoutOAuth2TokenManager( + OAuth2Config("test_id", "test_secret", "2.2", None), + timeout=7.0, + ) + tokens: list[str] = [] + + def get_token() -> None: + started.wait() + tokens.append(manager.get_token()) + + threads = [threading.Thread(target=get_token) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(tokens, ["test_token"] * 4) + self.assertEqual(mock_post.call_count, 1) diff --git a/tests/amazon_creatorsapi/core/error_handling_test.py b/tests/amazon_creatorsapi/core/error_handling_test.py index b09b14b..9265e91 100644 --- a/tests/amazon_creatorsapi/core/error_handling_test.py +++ b/tests/amazon_creatorsapi/core/error_handling_test.py @@ -137,6 +137,29 @@ def test_body_without_details(self) -> None: self.assertIn("Bad gateway", str(context.exception)) +class TestHandleApiErrorReason(unittest.TestCase): + """Tests for the reason reported when the response has no body.""" + + def test_reason_is_reported(self) -> None: + """Test that the reason is kept when the response has no body.""" + with self.assertRaises(RequestError) as context: + handle_api_error(0, "", reason="SSL error: certificate verify failed") + + self.assertIn("certificate verify failed", str(context.exception)) + + def test_body_wins_over_the_reason(self) -> None: + """Test that the body of the response is preferred to the reason.""" + with self.assertRaises(RequestError) as context: + handle_api_error( + 500, + '{"message": "Internal failure"}', + reason="Internal Server Error", + ) + + self.assertIn("Internal failure", str(context.exception)) + self.assertNotIn("Internal Server Error", str(context.exception)) + + class TestGetRequestId(unittest.TestCase): """Tests for get_request_id function.""" diff --git a/tests/amazon_creatorsapi/core/oauth_test.py b/tests/amazon_creatorsapi/core/oauth_test.py new file mode 100644 index 0000000..31d7d3b --- /dev/null +++ b/tests/amazon_creatorsapi/core/oauth_test.py @@ -0,0 +1,71 @@ +"""Unit tests for the shared OAuth2 settings.""" + +from __future__ import annotations + +import unittest + +from amazon_creatorsapi.core.oauth import ( + COGNITO_SCOPE, + LWA_SCOPE, + VERSION_ENDPOINTS, + get_auth_endpoint, + get_scope, + is_lwa, +) + + +class TestIsLwa(unittest.TestCase): + """Tests for is_lwa function.""" + + def test_lwa_versions(self) -> None: + """Test that the 3.x versions authenticate with LWA.""" + for version in ("3.1", "3.2", "3.3"): + self.assertTrue(is_lwa(version)) + + def test_cognito_versions(self) -> None: + """Test that the 2.x versions authenticate with Cognito.""" + for version in ("2.1", "2.2", "2.3"): + self.assertFalse(is_lwa(version)) + + +class TestGetScope(unittest.TestCase): + """Tests for get_scope function.""" + + def test_scope_of_every_version(self) -> None: + """Test that every version asks for the scope of its flow.""" + self.assertEqual(get_scope("3.1"), LWA_SCOPE) + self.assertEqual(get_scope("2.2"), COGNITO_SCOPE) + + +class TestGetAuthEndpoint(unittest.TestCase): + """Tests for get_auth_endpoint function.""" + + def test_endpoint_of_every_version(self) -> None: + """Test that every supported version resolves to its endpoint.""" + for version, endpoint in VERSION_ENDPOINTS.items(): + self.assertEqual(get_auth_endpoint(version), endpoint) + + def test_custom_endpoint_wins(self) -> None: + """Test that the endpoint given by the user is the one used.""" + self.assertEqual( + get_auth_endpoint("2.2", "https://example.test/token"), + "https://example.test/token", + ) + + def test_custom_endpoint_makes_any_version_valid(self) -> None: + """Test that a custom endpoint accepts a version out of the list.""" + self.assertEqual( + get_auth_endpoint("4.0", "https://example.test/token"), + "https://example.test/token", + ) + + def test_blank_endpoint_is_ignored(self) -> None: + """Test that a blank endpoint falls back to the one of the version.""" + self.assertEqual(get_auth_endpoint("2.2", " "), VERSION_ENDPOINTS["2.2"]) + + def test_unsupported_version_is_rejected(self) -> None: + """Test that an unknown version without an endpoint is rejected.""" + with self.assertRaises(ValueError) as context: + get_auth_endpoint("4.0") + + self.assertIn("Unsupported version: 4.0", str(context.exception)) diff --git a/tests/amazon_creatorsapi/core/retry_test.py b/tests/amazon_creatorsapi/core/retry_test.py index 14ffa7a..273f38e 100644 --- a/tests/amazon_creatorsapi/core/retry_test.py +++ b/tests/amazon_creatorsapi/core/retry_test.py @@ -3,6 +3,8 @@ from __future__ import annotations import unittest +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime from amazon_creatorsapi.core.retry import ( MAX_BACKOFF, @@ -43,9 +45,37 @@ def test_missing_header(self) -> None: self.assertIsNone(get_retry_after(None)) self.assertIsNone(get_retry_after({"Content-Type": "application/json"})) - def test_date_header_is_ignored(self) -> None: - """Test that a header holding a date is ignored.""" - self.assertIsNone(get_retry_after({"Retry-After": "Wed, 21 Oct 2026 07:28:00"})) + def test_reads_a_date_header(self) -> None: + """Test that a header holding a date is read as the seconds left.""" + date = datetime.now(timezone.utc) + timedelta(seconds=120) + seconds = get_retry_after({"Retry-After": format_datetime(date, usegmt=True)}) + + self.assertIsNotNone(seconds) + assert seconds is not None + self.assertAlmostEqual(seconds, 120, delta=5) + + def test_date_header_in_the_past(self) -> None: + """Test that a date already past asks for no wait at all.""" + date = datetime.now(timezone.utc) - timedelta(seconds=120) + + self.assertEqual( + get_retry_after({"Retry-After": format_datetime(date, usegmt=True)}), + 0.0, + ) + + def test_date_header_without_timezone(self) -> None: + """Test that a date without timezone is read as UTC.""" + date = datetime.now(timezone.utc) + timedelta(seconds=120) + header = date.strftime("%a, %d %b %Y %H:%M:%S") + seconds = get_retry_after({"Retry-After": header}) + + self.assertIsNotNone(seconds) + assert seconds is not None + self.assertAlmostEqual(seconds, 120, delta=5) + + def test_invalid_header_is_ignored(self) -> None: + """Test that a header holding neither seconds nor a date is ignored.""" + self.assertIsNone(get_retry_after({"Retry-After": "soon"})) class TestGetRetryDelay(unittest.TestCase): diff --git a/tests/amazon_creatorsapi/core/validation_test.py b/tests/amazon_creatorsapi/core/validation_test.py index a59cd4e..e9a5c2a 100644 --- a/tests/amazon_creatorsapi/core/validation_test.py +++ b/tests/amazon_creatorsapi/core/validation_test.py @@ -8,6 +8,7 @@ build_request, validate_retries, validate_search_criteria, + validate_throttling, validate_timeout, ) from amazon_creatorsapi.errors import InvalidArgumentError @@ -60,6 +61,11 @@ def test_zero_is_rejected(self) -> None: with self.assertRaises(InvalidArgumentError): validate_timeout(0) + def test_not_a_number_is_rejected(self) -> None: + """Test that a value that is not a number is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_timeout("slow") # type: ignore[arg-type] + class TestValidateRetries(unittest.TestCase): """Tests for validate_retries function.""" @@ -77,6 +83,33 @@ def test_negative_is_rejected(self) -> None: with self.assertRaises(InvalidArgumentError): validate_retries(-1) + def test_not_a_number_is_rejected(self) -> None: + """Test that a value that is not a whole number is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_retries("many") # type: ignore[arg-type] + + +class TestValidateThrottling(unittest.TestCase): + """Tests for validate_throttling function.""" + + def test_accepts_a_wait_time(self) -> None: + """Test that a wait time is returned as a float.""" + self.assertEqual(validate_throttling(2), 2.0) + + def test_accepts_no_wait_time(self) -> None: + """Test that no wait between calls is accepted.""" + self.assertEqual(validate_throttling(0), 0.0) + + def test_negative_is_rejected(self) -> None: + """Test that a negative wait time is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_throttling(-1) + + def test_not_a_number_is_rejected(self) -> None: + """Test that a value that is not a number is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_throttling("fast") # type: ignore[arg-type] + class TestValidateSearchCriteria(unittest.TestCase): """Tests for validate_search_criteria function.""" diff --git a/tests/amazon_creatorsapi/tools_test.py b/tests/amazon_creatorsapi/tools_test.py index db62d9b..d57814e 100644 --- a/tests/amazon_creatorsapi/tools_test.py +++ b/tests/amazon_creatorsapi/tools_test.py @@ -49,6 +49,12 @@ def test_get_asin_with_complex_url(self) -> None: url = "https://www.amazon.com/Product-Name-Description/dp/B0DLFMFBJW/ref=sr_1_1" self.assertEqual(get_asin(url), "B0DLFMFBJW") + def test_get_asin_with_a_longer_identifier_raises_error(self) -> None: + """Test that an identifier longer than an ASIN is not trimmed.""" + url = "https://www.amazon.es/dp/B0DLFMFBJW1234" + with self.assertRaises(InvalidArgumentError): + get_asin(url) + def test_get_asin_with_invalid_input_raises_error(self) -> None: """Test that invalid input raises InvalidArgumentError.""" with self.assertRaises(InvalidArgumentError):