From 5be0f58e35180b6a61cca65d37ead682d00131a5 Mon Sep 17 00:00:00 2001 From: Enirsa Date: Wed, 11 Mar 2026 15:18:00 +0200 Subject: [PATCH 1/2] Bumped `creatorsapi-python-sdk` from 1.1.2 to 1.2.0 which introduces LWA endpoints (v3.x), added LWA support to the aio version of the API too --- CHANGELOG.md | 11 ++ amazon_creatorsapi/aio/api.py | 11 +- amazon_creatorsapi/aio/auth.py | 39 +++++-- creatorsapi_python_sdk/__init__.py | 1 + creatorsapi_python_sdk/api/default_api.py | 15 +++ creatorsapi_python_sdk/api_client.py | 9 +- creatorsapi_python_sdk/auth/oauth2_config.py | 24 ++++- .../auth/oauth2_token_manager.py | 45 +++++--- creatorsapi_python_sdk/models/__init__.py | 1 + .../models/variation_summary.py | 8 +- .../models/variation_summary_price.py | 101 ++++++++++++++++++ tests/amazon_creatorsapi/aio/api_test.py | 87 +++++++++++++++ tests/amazon_creatorsapi/aio/auth_test.py | 67 +++++++++++- 13 files changed, 378 insertions(+), 41 deletions(-) create mode 100644 creatorsapi_python_sdk/models/variation_summary_price.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fccb5ea..a5a3461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- LWA support in the `amazon_creatorsapi.aio` API layer + +### Changed + +- Bumped `creatorsapi-python-sdk` from `1.1.2` to `1.2.0` +- Updated bundled SDK support to include LWA endpoints for v3.x + ## [6.1.0] - 2026-02-09 ### Added diff --git a/amazon_creatorsapi/aio/api.py b/amazon_creatorsapi/aio/api.py index 483e6ee..07ecbab 100644 --- a/amazon_creatorsapi/aio/api.py +++ b/amazon_creatorsapi/aio/api.py @@ -106,7 +106,8 @@ class AsyncAmazonCreatorsApi: Raises: InvalidArgumentError: If neither country nor marketplace is provided. - ValueError: If version is not supported (valid versions: 2.1, 2.2, 2.3). + ValueError: If version is not supported (valid versions: 2.1, 2.2, 2.3, + 3.1, 3.2, 3.3). """ @@ -483,7 +484,7 @@ async def _make_request( token = await self._token_manager.get_token() headers = { - "Authorization": f"Bearer {token}, Version {self._version}", + "Authorization": self._build_authorization_header(token), "Content-Type": "application/json; charset=utf-8", "x-marketplace": self.marketplace, } @@ -501,6 +502,12 @@ async def _make_request( return response.json() + def _build_authorization_header(self, token: str) -> str: + """Build the version-appropriate Authorization header.""" + if self._version.startswith("3."): + return f"Bearer {token}" + return f"Bearer {token}, Version {self._version}" + def _handle_error_response(self, status_code: int, body: str) -> None: """Handle API error responses and raise appropriate exceptions. diff --git a/amazon_creatorsapi/aio/auth.py b/amazon_creatorsapi/aio/auth.py index 737f1b7..1f3878f 100644 --- a/amazon_creatorsapi/aio/auth.py +++ b/amazon_creatorsapi/aio/auth.py @@ -21,7 +21,10 @@ # OAuth2 constants -SCOPE = "creatorsapi/default" +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) @@ -32,6 +35,9 @@ "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", } @@ -97,6 +103,14 @@ def _determine_auth_endpoint( return VERSION_ENDPOINTS[version] + def is_lwa(self) -> bool: + """Return whether this token manager uses the LWA auth flow.""" + return self._version.startswith("3.") + + def get_scope(self) -> str: + """Return the version-appropriate OAuth2 scope.""" + return LWA_SCOPE if self.is_lwa() else COGNITO_SCOPE + @property def lock(self) -> asyncio.Lock: """Lazy initialization of the asyncio.Lock. @@ -168,20 +182,23 @@ async def refresh_token(self) -> str: "grant_type": GRANT_TYPE, "client_id": self._credential_id, "client_secret": self._credential_secret, - "scope": SCOPE, - } - - headers = { - "Content-Type": "application/x-www-form-urlencoded", + "scope": self.get_scope(), } try: async with httpx.AsyncClient() as client: - response = await client.post( - self._auth_endpoint, - data=request_data, - headers=headers, - ) + if self.is_lwa(): + response = await client.post( + self._auth_endpoint, + json=request_data, + headers={"Content-Type": "application/json"}, + ) + else: + response = await client.post( + self._auth_endpoint, + data=request_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) if response.status_code != 200: # noqa: PLR2004 self.clear_token() diff --git a/creatorsapi_python_sdk/__init__.py b/creatorsapi_python_sdk/__init__.py index 3ea45ed..23da967 100644 --- a/creatorsapi_python_sdk/__init__.py +++ b/creatorsapi_python_sdk/__init__.py @@ -123,5 +123,6 @@ from creatorsapi_python_sdk.models.variation_attribute import VariationAttribute from creatorsapi_python_sdk.models.variation_dimension import VariationDimension from creatorsapi_python_sdk.models.variation_summary import VariationSummary +from creatorsapi_python_sdk.models.variation_summary_price import VariationSummaryPrice from creatorsapi_python_sdk.models.variations_result import VariationsResult from creatorsapi_python_sdk.models.website_sales_rank import WebsiteSalesRank diff --git a/creatorsapi_python_sdk/api/default_api.py b/creatorsapi_python_sdk/api/default_api.py index 8efe704..5816d42 100644 --- a/creatorsapi_python_sdk/api/default_api.py +++ b/creatorsapi_python_sdk/api/default_api.py @@ -424,6 +424,7 @@ def get_feed( '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -500,6 +501,7 @@ def get_feed_with_http_info( '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -576,6 +578,7 @@ def get_feed_without_preload_content( '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -1026,6 +1029,7 @@ def get_report( '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -1102,6 +1106,7 @@ def get_report_with_http_info( '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -1178,6 +1183,7 @@ def get_report_without_preload_content( '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -1627,6 +1633,7 @@ def list_feeds( '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -1699,6 +1706,7 @@ def list_feeds_with_http_info( '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -1771,6 +1779,7 @@ def list_feeds_without_preload_content( '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -1899,6 +1908,8 @@ def list_reports( '400': "ValidationExceptionResponseContent", '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", + '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -1970,6 +1981,8 @@ def list_reports_with_http_info( '400': "ValidationExceptionResponseContent", '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", + '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( @@ -2041,6 +2054,8 @@ def list_reports_without_preload_content( '400': "ValidationExceptionResponseContent", '401': "UnauthorizedExceptionResponseContent", '403': "AccessDeniedExceptionResponseContent", + '404': "ResourceNotFoundExceptionResponseContent", + '429': "ThrottleExceptionResponseContent", '500': "InternalServerExceptionResponseContent", } response_data = self.api_client.call_api( diff --git a/creatorsapi_python_sdk/api_client.py b/creatorsapi_python_sdk/api_client.py index a002106..41f2b17 100644 --- a/creatorsapi_python_sdk/api_client.py +++ b/creatorsapi_python_sdk/api_client.py @@ -107,7 +107,7 @@ def __init__( self.default_headers[header_name] = header_value self.cookie = cookie # Set default User-Agent. - self.user_agent = 'creatorsapi-python-sdk/1.1.2' + self.user_agent = 'creatorsapi-python-sdk/1.2.0' self.client_side_validation = configuration.client_side_validation # OAuth2 properties @@ -387,8 +387,11 @@ def call_api( self._token_manager = OAuth2TokenManager(config) # Get token (will use cached token if valid) token = self._token_manager.get_token() - # Add Authorization headers - header_params['Authorization'] = 'Bearer {}, Version {}'.format(token, self.version) + # Add Authorization headers - Version only for v2.x + if self.version.startswith("3."): + header_params['Authorization'] = 'Bearer {}'.format(token) + else: + header_params['Authorization'] = 'Bearer {}, Version {}'.format(token, self.version) except Exception as error: raise error diff --git a/creatorsapi_python_sdk/auth/oauth2_config.py b/creatorsapi_python_sdk/auth/oauth2_config.py index 3bad744..6a826eb 100644 --- a/creatorsapi_python_sdk/auth/oauth2_config.py +++ b/creatorsapi_python_sdk/auth/oauth2_config.py @@ -24,7 +24,8 @@ class OAuth2Config: """OAuth2 configuration class that manages version-specific cognito endpoints""" # Constants - SCOPE = "creatorsapi/default" + COGNITO_SCOPE = "creatorsapi/default" + LWA_SCOPE = "creatorsapi::default" GRANT_TYPE = "client_credentials" def __init__(self, credential_id, credential_secret, version, auth_endpoint): @@ -54,15 +55,30 @@ def determine_token_endpoint(self, version, auth_endpoint): if auth_endpoint and auth_endpoint.strip(): return auth_endpoint - # Fall back to version-based defaults + # Cognito endpoints (v2.x) if version == "2.1": return "https://creatorsapi.auth.us-east-1.amazoncognito.com/oauth2/token" elif version == "2.2": return "https://creatorsapi.auth.eu-south-2.amazoncognito.com/oauth2/token" elif version == "2.3": return "https://creatorsapi.auth.us-west-2.amazoncognito.com/oauth2/token" + # LWA endpoints (v3.x) + elif version == "3.1": + return "https://api.amazon.com/auth/o2/token" + elif version == "3.2": + return "https://api.amazon.co.uk/auth/o2/token" + elif version == "3.3": + return "https://api.amazon.co.jp/auth/o2/token" else: - raise ValueError("Unsupported version: {}. Supported versions are: 2.1, 2.2, 2.3".format(version)) + raise ValueError("Unsupported version: {}. Supported versions are: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3".format(version)) + + def is_lwa(self): + """ + Checks if this is an LWA (v3.x) configuration + + :return: True if using LWA authentication + """ + return self.version.startswith("3.") def get_token_endpoint(self, version): """ @@ -112,7 +128,7 @@ def get_scope(self): :return: The OAuth2 scope """ - return OAuth2Config.SCOPE + return OAuth2Config.LWA_SCOPE if self.is_lwa() else OAuth2Config.COGNITO_SCOPE def get_grant_type(self): """ diff --git a/creatorsapi_python_sdk/auth/oauth2_token_manager.py b/creatorsapi_python_sdk/auth/oauth2_token_manager.py index 58215d1..7a730fa 100644 --- a/creatorsapi_python_sdk/auth/oauth2_token_manager.py +++ b/creatorsapi_python_sdk/auth/oauth2_token_manager.py @@ -67,22 +67,35 @@ def refresh_token(self): :raises Exception: If token refresh fails """ try: - request_data = { - 'grant_type': self.config.get_grant_type(), - 'client_id': self.config.get_credential_id(), - 'client_secret': self.config.get_credential_secret(), - 'scope': self.config.get_scope() - } - - headers = { - 'Content-Type': 'application/x-www-form-urlencoded' - } - - response = requests.post( - self.config.get_cognito_endpoint(), - data=request_data, - headers=headers - ) + if self.config.is_lwa(): + # LWA (v3.x) uses JSON body + request_data = { + 'grant_type': self.config.get_grant_type(), + 'client_id': self.config.get_credential_id(), + 'client_secret': self.config.get_credential_secret(), + 'scope': self.config.get_scope() + } + headers = {'Content-Type': 'application/json'} + response = requests.post( + self.config.get_cognito_endpoint(), + json=request_data, + headers=headers + ) + else: + # Cognito (v2.x) uses form-encoded + request_data = { + 'grant_type': self.config.get_grant_type(), + 'client_id': self.config.get_credential_id(), + 'client_secret': self.config.get_credential_secret(), + 'scope': self.config.get_scope() + } + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + response = requests.post( + self.config.get_cognito_endpoint(), + data=request_data, + headers=headers + ) + if response.status_code != 200: raise Exception("OAuth2 token request failed with status {}: {}".format(response.status_code, response.text)) diff --git a/creatorsapi_python_sdk/models/__init__.py b/creatorsapi_python_sdk/models/__init__.py index 4de6c25..095b64e 100644 --- a/creatorsapi_python_sdk/models/__init__.py +++ b/creatorsapi_python_sdk/models/__init__.py @@ -106,5 +106,6 @@ from creatorsapi_python_sdk.models.variation_attribute import VariationAttribute from creatorsapi_python_sdk.models.variation_dimension import VariationDimension from creatorsapi_python_sdk.models.variation_summary import VariationSummary +from creatorsapi_python_sdk.models.variation_summary_price import VariationSummaryPrice from creatorsapi_python_sdk.models.variations_result import VariationsResult from creatorsapi_python_sdk.models.website_sales_rank import WebsiteSalesRank diff --git a/creatorsapi_python_sdk/models/variation_summary.py b/creatorsapi_python_sdk/models/variation_summary.py index 0b63321..a033e3c 100644 --- a/creatorsapi_python_sdk/models/variation_summary.py +++ b/creatorsapi_python_sdk/models/variation_summary.py @@ -26,6 +26,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictFloat, StrictInt from typing import Any, ClassVar, Dict, List, Optional, Union from creatorsapi_python_sdk.models.variation_dimension import VariationDimension +from creatorsapi_python_sdk.models.variation_summary_price import VariationSummaryPrice from typing import Optional, Set from typing_extensions import Self @@ -34,9 +35,10 @@ class VariationSummary(BaseModel): The container for Variations Summary response. It consists of metadata of variations response like page numbers, number of variations, Price range and Variation Dimensions. """ # noqa: E501 page_count: Optional[Union[StrictFloat, StrictInt]] = Field(default=None, description="Number of pages in the variation result set.", alias="pageCount") + price: Optional[VariationSummaryPrice] = None variation_count: Optional[Union[StrictFloat, StrictInt]] = Field(default=None, description="Total number of variations available for the product. This represents the complete count of all child ASINs across all pages. Use this value along with pageCount to understand the full scope of available variations.", alias="variationCount") variation_dimensions: Optional[List[VariationDimension]] = Field(default=None, description="List of variation dimensions associated with the product. Variation dimensions define the attributes on which products vary (e.g., size, color). Each dimension includes: - Display name and locale for presentation - Dimension name (internal identifier) - List of all possible values for that dimension For example, a clothing item might have two dimensions: 'Size' with values ['S', 'M', 'L'] and 'Color' with values ['Red', 'Blue', 'Green']. These dimensions help users understand how variations differ from each other.", alias="variationDimensions") - __properties: ClassVar[List[str]] = ["pageCount", "variationCount", "variationDimensions"] + __properties: ClassVar[List[str]] = ["pageCount", "price", "variationCount", "variationDimensions"] model_config = ConfigDict( populate_by_name=True, @@ -76,6 +78,9 @@ def to_dict(self) -> Dict[str, Any]: exclude=excluded_fields, exclude_none=True, ) + # override the default output from pydantic by calling `to_dict()` of price + if self.price: + _dict['price'] = self.price.to_dict() # override the default output from pydantic by calling `to_dict()` of each item in variation_dimensions (list) _items = [] if self.variation_dimensions: @@ -96,6 +101,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "pageCount": obj.get("pageCount"), + "price": VariationSummaryPrice.from_dict(obj["price"]) if obj.get("price") is not None else None, "variationCount": obj.get("variationCount"), "variationDimensions": [VariationDimension.from_dict(_item) for _item in obj["variationDimensions"]] if obj.get("variationDimensions") is not None else None }) diff --git a/creatorsapi_python_sdk/models/variation_summary_price.py b/creatorsapi_python_sdk/models/variation_summary_price.py new file mode 100644 index 0000000..74f0743 --- /dev/null +++ b/creatorsapi_python_sdk/models/variation_summary_price.py @@ -0,0 +1,101 @@ +# coding: utf-8 + +""" +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + +or in the "license" file accompanying this file. This file is distributed +on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +express or implied. See the License for the specific language governing +permissions and limitations under the License. + +""" # noqa: E501 + + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field +from typing import Any, ClassVar, Dict, List, Optional +from creatorsapi_python_sdk.models.money import Money +from typing import Optional, Set +from typing_extensions import Self + +class VariationSummaryPrice(BaseModel): + """ + The container for highest and lowest price for variations. + """ # noqa: E501 + highest_price: Optional[Money] = Field(default=None, alias="highestPrice") + lowest_price: Optional[Money] = Field(default=None, alias="lowestPrice") + __properties: ClassVar[List[str]] = ["highestPrice", "lowestPrice"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + return self.model_dump_json(by_alias=True, exclude_unset=True) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of VariationSummaryPrice from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of highest_price + if self.highest_price: + _dict['highestPrice'] = self.highest_price.to_dict() + # override the default output from pydantic by calling `to_dict()` of lowest_price + if self.lowest_price: + _dict['lowestPrice'] = self.lowest_price.to_dict() + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of VariationSummaryPrice from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "highestPrice": Money.from_dict(obj["highestPrice"]) if obj.get("highestPrice") is not None else None, + "lowestPrice": Money.from_dict(obj["lowestPrice"]) if obj.get("lowestPrice") is not None else None + }) + return _obj + + diff --git a/tests/amazon_creatorsapi/aio/api_test.py b/tests/amazon_creatorsapi/aio/api_test.py index 6e7fcb7..76ef45d 100644 --- a/tests/amazon_creatorsapi/aio/api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -68,6 +68,19 @@ def test_with_custom_throttling(self, mock_token_manager: MagicMock) -> None: self.assertEqual(api.throttling, 2.5) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_accepts_lwa_version(self, mock_token_manager: MagicMock) -> None: + """Test initialization accepts an LWA-backed 3.x version.""" + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="3.1", + tag="test-tag", + country="US", + ) + + self.assertEqual(api.marketplace, "www.amazon.com") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") def test_raises_error_when_no_country_or_marketplace( self, mock_token_manager: MagicMock @@ -1204,6 +1217,80 @@ async def test_request_without_context_manager( self.assertEqual(len(items), 1) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_request_uses_v2_authorization_header( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test v2 requests include the version suffix in Authorization.""" + 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, + ) + + await api.get_items(["B0DLFMFBJW"]) + + headers = mock_client.post.call_args.args[1] + self.assertEqual(headers["Authorization"], "Bearer test_token, Version 2.2") + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_request_uses_lwa_authorization_header( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test v3 requests omit the version suffix in Authorization.""" + 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="3.1", + tag="test-tag", + country="US", + throttling=0, + ) + + await api.get_items(["B0DLFMFBJW"]) + + headers = mock_client.post.call_args.args[1] + self.assertEqual(headers["Authorization"], "Bearer test_token") + if __name__ == "__main__": unittest.main() diff --git a/tests/amazon_creatorsapi/aio/auth_test.py b/tests/amazon_creatorsapi/aio/auth_test.py index 7cf5abb..b0bd456 100644 --- a/tests/amazon_creatorsapi/aio/auth_test.py +++ b/tests/amazon_creatorsapi/aio/auth_test.py @@ -7,8 +7,9 @@ import httpx from amazon_creatorsapi.aio.auth import ( + COGNITO_SCOPE, GRANT_TYPE, - SCOPE, + LWA_SCOPE, TOKEN_EXPIRATION_BUFFER, VERSION_ENDPOINTS, AsyncOAuth2TokenManager, @@ -52,6 +53,18 @@ def test_with_version_23(self) -> None: self.assertEqual(manager._auth_endpoint, VERSION_ENDPOINTS["2.3"]) + def test_with_version_31(self) -> None: + """Test initialization with version 3.1.""" + manager = AsyncOAuth2TokenManager( + credential_id="test_id", + credential_secret="test_secret", + version="3.1", + ) + + self.assertEqual(manager._version, "3.1") + self.assertEqual(manager._auth_endpoint, VERSION_ENDPOINTS["3.1"]) + self.assertTrue(manager.is_lwa()) + def test_with_custom_endpoint(self) -> None: """Test initialization with custom auth endpoint.""" custom_endpoint = "https://custom.auth.endpoint/token" @@ -75,6 +88,18 @@ def test_with_invalid_version(self) -> None: self.assertIn("Unsupported version", str(context.exception)) + def test_returns_cognito_scope_for_v2(self) -> None: + """Test v2 versions use the Cognito scope.""" + manager = AsyncOAuth2TokenManager("id", "secret", "2.2") + + self.assertEqual(manager.get_scope(), COGNITO_SCOPE) + + def test_returns_lwa_scope_for_v3(self) -> None: + """Test v3 versions use the LWA scope.""" + manager = AsyncOAuth2TokenManager("id", "secret", "3.1") + + self.assertEqual(manager.get_scope(), LWA_SCOPE) + class TestAsyncOAuth2TokenManagerIsTokenValid(unittest.TestCase): """Tests for is_token_valid() method.""" @@ -226,9 +251,43 @@ async def test_successful_token_refresh( ) # 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)) + call_kwargs = mock_client.post.call_args.kwargs + self.assertEqual(call_kwargs["data"]["grant_type"], GRANT_TYPE) + self.assertEqual(call_kwargs["data"]["scope"], COGNITO_SCOPE) + self.assertEqual( + call_kwargs["headers"]["Content-Type"], + "application/x-www-form-urlencoded", + ) + self.assertNotIn("json", call_kwargs) + + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") + async def test_successful_token_refresh_for_lwa( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Test LWA token refresh uses JSON payload and LWA scope.""" + 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", "3.1") + + token = await manager.refresh_token() + + self.assertEqual(token, "fresh_token") + call_kwargs = mock_client.post.call_args.kwargs + self.assertEqual(call_kwargs["json"]["grant_type"], GRANT_TYPE) + self.assertEqual(call_kwargs["json"]["scope"], LWA_SCOPE) + self.assertEqual(call_kwargs["headers"]["Content-Type"], "application/json") + self.assertNotIn("data", call_kwargs) @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") async def test_raises_error_on_non_200_response( From 13f4bcdbf855c8bab6ae02dddfbad455548fab5e Mon Sep 17 00:00:00 2001 From: Sergio Abad Date: Thu, 12 Mar 2026 22:41:28 +0100 Subject: [PATCH 2/2] chore: bump project version to 6.2.0 --- CHANGELOG.md | 2 +- docs/conf.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5a3461..994adcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [6.2.0] - 2026-03-12 ### Added diff --git a/docs/conf.py b/docs/conf.py index ab93c66..b6439d0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ author = "Sergio Abad" # The full version, including alpha/beta/rc tags -release = "6.1.0" +release = "6.2.0" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 5708a24..be5adce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-amazon-paapi" -version = "6.1.0" +version = "6.2.0" description = "Amazon Product Advertising API 5.0 wrapper for Python" readme = "README.md" requires-python = ">=3.9"