Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

## [6.2.0] - 2026-03-12

### 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
Expand Down
11 changes: 9 additions & 2 deletions amazon_creatorsapi/aio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

"""

Expand Down Expand Up @@ -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,
}
Expand All @@ -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.

Expand Down
39 changes: 28 additions & 11 deletions amazon_creatorsapi/aio/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",
}


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions creatorsapi_python_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions creatorsapi_python_sdk/api/default_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ def get_feed(
'401': "UnauthorizedExceptionResponseContent",
'403': "AccessDeniedExceptionResponseContent",
'404': "ResourceNotFoundExceptionResponseContent",
'429': "ThrottleExceptionResponseContent",
'500': "InternalServerExceptionResponseContent",
}
response_data = self.api_client.call_api(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1026,6 +1029,7 @@ def get_report(
'401': "UnauthorizedExceptionResponseContent",
'403': "AccessDeniedExceptionResponseContent",
'404': "ResourceNotFoundExceptionResponseContent",
'429': "ThrottleExceptionResponseContent",
'500': "InternalServerExceptionResponseContent",
}
response_data = self.api_client.call_api(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1627,6 +1633,7 @@ def list_feeds(
'401': "UnauthorizedExceptionResponseContent",
'403': "AccessDeniedExceptionResponseContent",
'404': "ResourceNotFoundExceptionResponseContent",
'429': "ThrottleExceptionResponseContent",
'500': "InternalServerExceptionResponseContent",
}
response_data = self.api_client.call_api(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 6 additions & 3 deletions creatorsapi_python_sdk/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
24 changes: 20 additions & 4 deletions creatorsapi_python_sdk/auth/oauth2_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down
45 changes: 29 additions & 16 deletions creatorsapi_python_sdk/auth/oauth2_token_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
1 change: 1 addition & 0 deletions creatorsapi_python_sdk/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion creatorsapi_python_sdk/models/variation_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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
})
Expand Down
Loading