python-amazon-paapi 6.0.0__py3-none-any.whl → 6.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ """Async support for Amazon Creators API."""
2
+
3
+ try:
4
+ import httpx # noqa: F401
5
+ except ImportError as exc: # pragma: no cover
6
+ msg = (
7
+ "httpx is required for async support. "
8
+ "Install it with: pip install python-amazon-paapi[async]"
9
+ )
10
+ raise ImportError(msg) from exc
11
+
12
+ from amazon_creatorsapi.aio.api import AsyncAmazonCreatorsApi
13
+
14
+ __all__ = ["AsyncAmazonCreatorsApi"]
@@ -0,0 +1,551 @@
1
+ """Async Amazon Creators API wrapper for Python.
2
+
3
+ Provides async methods to interact with the Amazon Creators API.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import time
10
+ from enum import Enum
11
+ from typing import TYPE_CHECKING, Any, TypeVar
12
+
13
+ from typing_extensions import Self
14
+
15
+ from amazon_creatorsapi.core.constants import DEFAULT_THROTTLING
16
+ from amazon_creatorsapi.core.error_handling import handle_api_error
17
+ from amazon_creatorsapi.core.parsers import get_asin, get_items_ids
18
+ from amazon_creatorsapi.core.resources import get_all_resources
19
+ from amazon_creatorsapi.core.validation import validate_and_get_marketplace
20
+ from amazon_creatorsapi.errors import ItemsNotFoundError
21
+
22
+ try:
23
+ from .auth import VERSION_ENDPOINTS, AsyncOAuth2TokenManager
24
+ from .client import AsyncHttpClient
25
+ except ImportError as exc: # pragma: no cover
26
+ msg = (
27
+ "httpx is required for async support. "
28
+ "Install it with: pip install python-amazon-paapi[async]"
29
+ )
30
+ raise ImportError(msg) from exc
31
+
32
+ from creatorsapi_python_sdk.models.get_browse_nodes_resource import (
33
+ GetBrowseNodesResource,
34
+ )
35
+ from creatorsapi_python_sdk.models.get_items_resource import GetItemsResource
36
+ from creatorsapi_python_sdk.models.get_variations_resource import GetVariationsResource
37
+ from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource
38
+
39
+ if TYPE_CHECKING:
40
+ from types import TracebackType
41
+
42
+ from amazon_creatorsapi.core.marketplaces import CountryCode
43
+ from creatorsapi_python_sdk.models.condition import Condition
44
+ from creatorsapi_python_sdk.models.sort_by import SortBy
45
+
46
+ from creatorsapi_python_sdk.models.browse_node import BrowseNode
47
+ from creatorsapi_python_sdk.models.item import Item
48
+ from creatorsapi_python_sdk.models.search_result import SearchResult
49
+ from creatorsapi_python_sdk.models.variations_result import VariationsResult
50
+
51
+ # API endpoints
52
+ API_HOST = "https://creatorsapi.amazon"
53
+ ENDPOINT_GET_ITEMS = "/catalog/v1/getItems"
54
+ ENDPOINT_SEARCH_ITEMS = "/catalog/v1/searchItems"
55
+ ENDPOINT_GET_VARIATIONS = "/catalog/v1/getVariations"
56
+ ENDPOINT_GET_BROWSE_NODES = "/catalog/v1/getBrowseNodes"
57
+
58
+ # TypeVar for generic resource handling
59
+ ResourceT = TypeVar("ResourceT", bound=Enum)
60
+
61
+
62
+ class AsyncAmazonCreatorsApi:
63
+ """Async version of Amazon Creators API wrapper.
64
+
65
+ Provides async methods to get information from Amazon using the Creators API.
66
+ This class can be used with or without a context manager.
67
+
68
+ Basic usage (creates new HTTP connection per request):
69
+ >>> api = AsyncAmazonCreatorsApi(
70
+ ... credential_id="your_id",
71
+ ... credential_secret="your_secret",
72
+ ... version="2.2",
73
+ ... tag="your-tag",
74
+ ... country="ES"
75
+ ... )
76
+ >>> items = await api.get_items(["B0DLFMFBJW"])
77
+
78
+ Advanced usage with context manager (reuses HTTP connection):
79
+ >>> async with AsyncAmazonCreatorsApi(
80
+ ... credential_id="your_id",
81
+ ... credential_secret="your_secret",
82
+ ... version="2.2",
83
+ ... tag="your-tag",
84
+ ... country="ES"
85
+ ... ) as api:
86
+ ... items = await api.get_items(["B0DLFMFBJW"])
87
+
88
+ The context manager approach is more efficient when making multiple
89
+ requests in quick succession due to HTTP connection pooling.
90
+
91
+ Note:
92
+ Using without context manager creates a new HTTP connection for each
93
+ request, which is less efficient and may impact performance. For
94
+ production code making multiple API calls, always use the context
95
+ manager (async with) to benefit from connection pooling and reduced
96
+ overhead.
97
+
98
+ Args:
99
+ credential_id: Your Creators API credential ID.
100
+ credential_secret: Your Creators API credential secret.
101
+ version: API version for your region.
102
+ tag: Your affiliate tracking id (partner tag).
103
+ country: Country code (e.g., "ES", "US"). Used to determine marketplace.
104
+ marketplace: Marketplace URL (e.g., "www.amazon.es"). Overrides country.
105
+ throttling: Wait time in seconds between API calls. Defaults to 1 second.
106
+
107
+ Raises:
108
+ InvalidArgumentError: If neither country nor marketplace is provided.
109
+ ValueError: If version is not supported (valid versions: 2.1, 2.2, 2.3,
110
+ 3.1, 3.2, 3.3).
111
+
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ credential_id: str,
117
+ credential_secret: str,
118
+ version: str,
119
+ tag: str,
120
+ country: CountryCode | None = None,
121
+ marketplace: str | None = None,
122
+ throttling: float = DEFAULT_THROTTLING,
123
+ ) -> None:
124
+ """Initialize the async Amazon Creators API client."""
125
+ # Validate version early to fail fast (before token manager initialization)
126
+ self._validate_version(version)
127
+
128
+ self._credential_id = credential_id
129
+ self._credential_secret = credential_secret
130
+ self._version = version
131
+ self._last_query_time = time.time() - throttling
132
+ self._throttle_lock: asyncio.Lock | None = None
133
+ self.tag = tag
134
+ self.throttling = float(throttling)
135
+
136
+ # Determine marketplace from country or direct value
137
+ self.marketplace = validate_and_get_marketplace(country, marketplace)
138
+
139
+ # HTTP client and token manager (initialized lazily or via context manager)
140
+ self._http_client: AsyncHttpClient | None = None
141
+ self._token_manager = AsyncOAuth2TokenManager(
142
+ credential_id=credential_id,
143
+ credential_secret=credential_secret,
144
+ version=version,
145
+ )
146
+ self._owns_client = False
147
+
148
+ def _validate_version(self, version: str) -> None:
149
+ """Validate that the API version is supported.
150
+
151
+ Args:
152
+ version: API version to validate.
153
+
154
+ Raises:
155
+ ValueError: If version is not in the list of supported versions.
156
+
157
+ """
158
+ if version not in VERSION_ENDPOINTS:
159
+ supported = ", ".join(VERSION_ENDPOINTS.keys())
160
+ msg = f"Unsupported version: {version}. Supported versions are: {supported}"
161
+ raise ValueError(msg)
162
+
163
+ async def __aenter__(self) -> Self:
164
+ """Enter async context manager, creating a persistent HTTP client."""
165
+ self._http_client = AsyncHttpClient(host=API_HOST)
166
+ await self._http_client.__aenter__()
167
+ self._owns_client = True
168
+ return self
169
+
170
+ async def __aexit__(
171
+ self,
172
+ exc_type: type[BaseException] | None,
173
+ exc_val: BaseException | None,
174
+ exc_tb: TracebackType | None,
175
+ ) -> None:
176
+ """Exit async context manager, closing the HTTP client."""
177
+ if self._http_client is not None and self._owns_client:
178
+ await self._http_client.__aexit__(exc_type, exc_val, exc_tb)
179
+ self._http_client = None
180
+ self._owns_client = False
181
+
182
+ async def get_items(
183
+ self,
184
+ items: str | list[str],
185
+ condition: Condition | None = None,
186
+ currency_of_preference: str | None = None,
187
+ languages_of_preference: list[str] | None = None,
188
+ resources: list[GetItemsResource] | None = None,
189
+ ) -> list[Item]:
190
+ """Get items information from Amazon.
191
+
192
+ Args:
193
+ items: One or more items, using ASIN or Amazon product URL.
194
+ Accepts a single string (comma-separated) or a list of strings.
195
+ condition: Filter offers by condition type.
196
+ currency_of_preference: ISO 4217 currency code for prices.
197
+ languages_of_preference: Languages in order of preference.
198
+ resources: List of resources to retrieve. Defaults to all.
199
+
200
+ Returns:
201
+ List of Item objects with Amazon information.
202
+
203
+ Raises:
204
+ ItemsNotFoundError: If no items are found.
205
+ InvalidArgumentError: If parameters are invalid.
206
+
207
+ """
208
+ if resources is None:
209
+ resources = get_all_resources(GetItemsResource)
210
+
211
+ item_ids = get_items_ids(items)
212
+
213
+ request_body = {
214
+ "partnerTag": self.tag,
215
+ "itemIds": item_ids,
216
+ "resources": [r.value for r in resources],
217
+ }
218
+ if condition is not None:
219
+ request_body["condition"] = condition.value
220
+ if currency_of_preference is not None:
221
+ request_body["currencyOfPreference"] = currency_of_preference
222
+ if languages_of_preference is not None:
223
+ request_body["languagesOfPreference"] = languages_of_preference
224
+
225
+ response = await self._make_request(ENDPOINT_GET_ITEMS, request_body)
226
+
227
+ items_result = response.get("itemsResult")
228
+ if items_result is None or items_result.get("items") is None:
229
+ msg = "No items have been found"
230
+ raise ItemsNotFoundError(msg)
231
+
232
+ return self._deserialize_items(items_result["items"])
233
+
234
+ async def search_items( # noqa: PLR0912, C901
235
+ self,
236
+ keywords: str | None = None,
237
+ actor: str | None = None,
238
+ artist: str | None = None,
239
+ author: str | None = None,
240
+ brand: str | None = None,
241
+ title: str | None = None,
242
+ browse_node_id: str | None = None,
243
+ search_index: str | None = None,
244
+ item_count: int | None = None,
245
+ item_page: int | None = None,
246
+ condition: Condition | None = None,
247
+ currency_of_preference: str | None = None,
248
+ languages_of_preference: list[str] | None = None,
249
+ max_price: int | None = None,
250
+ min_price: int | None = None,
251
+ min_saving_percent: int | None = None,
252
+ min_reviews_rating: int | None = None,
253
+ sort_by: SortBy | None = None,
254
+ resources: list[SearchItemsResource] | None = None,
255
+ ) -> SearchResult:
256
+ """Search for items on Amazon based on a search query.
257
+
258
+ At least one of the following parameters should be specified: keywords,
259
+ actor, artist, author, brand, title, browse_node_id or search_index.
260
+
261
+ Args:
262
+ keywords: A word or phrase that describes an item.
263
+ actor: Actor name associated with the item.
264
+ artist: Artist name associated with the item.
265
+ author: Author name associated with the item.
266
+ brand: Brand name associated with the item.
267
+ title: Title associated with the item.
268
+ browse_node_id: A unique ID for a product category.
269
+ search_index: Product category to search. Defaults to All.
270
+ item_count: Number of items returned (1-10). Defaults to 10.
271
+ item_page: Page of items to return (1-10). Defaults to 1.
272
+ condition: Filter offers by condition type.
273
+ currency_of_preference: ISO 4217 currency code for prices.
274
+ languages_of_preference: Languages in order of preference.
275
+ max_price: Max price in lowest currency denomination.
276
+ min_price: Min price in lowest currency denomination.
277
+ min_saving_percent: Min savings percentage (1-99).
278
+ min_reviews_rating: Min review rating (1-5).
279
+ sort_by: Sort method for results.
280
+ resources: List of resources to retrieve. Defaults to all.
281
+
282
+ Returns:
283
+ SearchResult containing the list of items.
284
+
285
+ Raises:
286
+ ItemsNotFoundError: If no items are found.
287
+
288
+ """
289
+ if resources is None:
290
+ resources = get_all_resources(SearchItemsResource)
291
+
292
+ request_body: dict[str, Any] = {
293
+ "partnerTag": self.tag,
294
+ "resources": [r.value for r in resources],
295
+ }
296
+
297
+ # Add optional parameters
298
+ if keywords is not None:
299
+ request_body["keywords"] = keywords
300
+ if actor is not None:
301
+ request_body["actor"] = actor
302
+ if artist is not None:
303
+ request_body["artist"] = artist
304
+ if author is not None:
305
+ request_body["author"] = author
306
+ if brand is not None:
307
+ request_body["brand"] = brand
308
+ if title is not None:
309
+ request_body["title"] = title
310
+ if browse_node_id is not None:
311
+ request_body["browseNodeId"] = browse_node_id
312
+ if search_index is not None:
313
+ request_body["searchIndex"] = search_index
314
+ if item_count is not None:
315
+ request_body["itemCount"] = item_count
316
+ if item_page is not None:
317
+ request_body["itemPage"] = item_page
318
+ if condition is not None:
319
+ request_body["condition"] = condition.value
320
+ if currency_of_preference is not None:
321
+ request_body["currencyOfPreference"] = currency_of_preference
322
+ if languages_of_preference is not None:
323
+ request_body["languagesOfPreference"] = languages_of_preference
324
+ if max_price is not None:
325
+ request_body["maxPrice"] = max_price
326
+ if min_price is not None:
327
+ request_body["minPrice"] = min_price
328
+ if min_saving_percent is not None:
329
+ request_body["minSavingPercent"] = min_saving_percent
330
+ if min_reviews_rating is not None:
331
+ request_body["minReviewsRating"] = min_reviews_rating
332
+ if sort_by is not None:
333
+ request_body["sortBy"] = sort_by.value
334
+
335
+ response = await self._make_request(ENDPOINT_SEARCH_ITEMS, request_body)
336
+
337
+ search_result = response.get("searchResult")
338
+ if search_result is None:
339
+ msg = "No items have been found"
340
+ raise ItemsNotFoundError(msg)
341
+
342
+ return self._deserialize_search_result(search_result)
343
+
344
+ async def get_variations(
345
+ self,
346
+ asin: str,
347
+ variation_count: int | None = None,
348
+ variation_page: int | None = None,
349
+ condition: Condition | None = None,
350
+ currency_of_preference: str | None = None,
351
+ languages_of_preference: list[str] | None = None,
352
+ resources: list[GetVariationsResource] | None = None,
353
+ ) -> VariationsResult:
354
+ """Return variations of a product (different sizes, colors, etc.).
355
+
356
+ Args:
357
+ asin: The ASIN or Amazon product URL of the product.
358
+ variation_count: Number of variations to return (1-10). Defaults to 10.
359
+ variation_page: Page of variations to return (1-10). Defaults to 1.
360
+ condition: Filter offers by condition type.
361
+ currency_of_preference: ISO 4217 currency code for prices.
362
+ languages_of_preference: Languages in order of preference.
363
+ resources: List of resources to retrieve. Defaults to all.
364
+
365
+ Returns:
366
+ VariationsResult containing the list of variations.
367
+
368
+ Raises:
369
+ ItemsNotFoundError: If no variations are found.
370
+
371
+ """
372
+ if resources is None:
373
+ resources = get_all_resources(GetVariationsResource)
374
+
375
+ asin = get_asin(asin)
376
+
377
+ request_body: dict[str, Any] = {
378
+ "partnerTag": self.tag,
379
+ "asin": asin,
380
+ "resources": [r.value for r in resources],
381
+ }
382
+
383
+ if variation_count is not None:
384
+ request_body["variationCount"] = variation_count
385
+ if variation_page is not None:
386
+ request_body["variationPage"] = variation_page
387
+ if condition is not None:
388
+ request_body["condition"] = condition.value
389
+ if currency_of_preference is not None:
390
+ request_body["currencyOfPreference"] = currency_of_preference
391
+ if languages_of_preference is not None:
392
+ request_body["languagesOfPreference"] = languages_of_preference
393
+
394
+ response = await self._make_request(ENDPOINT_GET_VARIATIONS, request_body)
395
+
396
+ variations_result = response.get("variationsResult")
397
+ if variations_result is None:
398
+ msg = "No variations have been found"
399
+ raise ItemsNotFoundError(msg)
400
+
401
+ return self._deserialize_variations_result(variations_result)
402
+
403
+ async def get_browse_nodes(
404
+ self,
405
+ browse_node_ids: list[str],
406
+ languages_of_preference: list[str] | None = None,
407
+ resources: list[GetBrowseNodesResource] | None = None,
408
+ ) -> list[BrowseNode]:
409
+ """Return browse node information including name, children, and ancestors.
410
+
411
+ Args:
412
+ browse_node_ids: List of browse node IDs.
413
+ languages_of_preference: Languages in order of preference.
414
+ resources: List of resources to retrieve. Defaults to all.
415
+
416
+ Returns:
417
+ List of BrowseNode objects.
418
+
419
+ Raises:
420
+ ItemsNotFoundError: If no browse nodes are found.
421
+
422
+ """
423
+ if resources is None:
424
+ resources = get_all_resources(GetBrowseNodesResource)
425
+
426
+ request_body: dict[str, Any] = {
427
+ "partnerTag": self.tag,
428
+ "browseNodeIds": browse_node_ids,
429
+ "resources": [r.value for r in resources],
430
+ }
431
+
432
+ if languages_of_preference is not None:
433
+ request_body["languagesOfPreference"] = languages_of_preference
434
+
435
+ response = await self._make_request(ENDPOINT_GET_BROWSE_NODES, request_body)
436
+
437
+ browse_nodes_result = response.get("browseNodesResult")
438
+ if (
439
+ browse_nodes_result is None
440
+ or browse_nodes_result.get("browseNodes") is None
441
+ ):
442
+ msg = "No browse nodes have been found"
443
+ raise ItemsNotFoundError(msg)
444
+
445
+ return self._deserialize_browse_nodes(browse_nodes_result["browseNodes"])
446
+
447
+ async def _throttle(self) -> None:
448
+ """Wait for the throttling interval to elapse since the last API call.
449
+
450
+ Uses asyncio.Lock to prevent race conditions when multiple coroutines
451
+ attempt to make concurrent requests.
452
+ """
453
+ # Lazy initialization of the lock (ensures event loop is active)
454
+ if self._throttle_lock is None:
455
+ self._throttle_lock = asyncio.Lock()
456
+
457
+ async with self._throttle_lock:
458
+ wait_time = self.throttling - (time.time() - self._last_query_time)
459
+ if wait_time > 0:
460
+ await asyncio.sleep(wait_time)
461
+ self._last_query_time = time.time()
462
+
463
+ async def _make_request(
464
+ self,
465
+ endpoint: str,
466
+ body: dict[str, Any],
467
+ ) -> dict[str, Any]:
468
+ """Make an API request with authentication and throttling.
469
+
470
+ Args:
471
+ endpoint: API endpoint path.
472
+ body: Request body.
473
+
474
+ Returns:
475
+ Parsed JSON response.
476
+
477
+ Raises:
478
+ Various exceptions based on API errors.
479
+
480
+ """
481
+ await self._throttle()
482
+
483
+ # Get auth token
484
+ token = await self._token_manager.get_token()
485
+
486
+ headers = {
487
+ "Authorization": self._build_authorization_header(token),
488
+ "Content-Type": "application/json; charset=utf-8",
489
+ "x-marketplace": self.marketplace,
490
+ }
491
+
492
+ # Use persistent client if available, otherwise create a new one
493
+ if self._http_client is not None:
494
+ response = await self._http_client.post(endpoint, headers, body)
495
+ else:
496
+ async with AsyncHttpClient(host=API_HOST) as client:
497
+ response = await client.post(endpoint, headers, body)
498
+
499
+ # Handle errors
500
+ if response.status_code != 200: # noqa: PLR2004
501
+ self._handle_error_response(response.status_code, response.text)
502
+
503
+ return response.json()
504
+
505
+ def _build_authorization_header(self, token: str) -> str:
506
+ """Build the version-appropriate Authorization header."""
507
+ if self._version.startswith("3."):
508
+ return f"Bearer {token}"
509
+ return f"Bearer {token}, Version {self._version}"
510
+
511
+ def _handle_error_response(self, status_code: int, body: str) -> None:
512
+ """Handle API error responses and raise appropriate exceptions.
513
+
514
+ Args:
515
+ status_code: HTTP status code.
516
+ body: Response body text.
517
+
518
+ Raises:
519
+ ItemsNotFoundError: For 404 errors.
520
+ TooManyRequestsError: For 429 errors.
521
+ InvalidArgumentError: For validation errors.
522
+ AssociateValidationError: For invalid associate credentials.
523
+ RequestError: For other errors.
524
+
525
+ """
526
+ handle_api_error(status_code, body)
527
+
528
+ def _deserialize_items(self, items_data: list[dict[str, Any]]) -> list[Item]:
529
+ """Deserialize item data from API response to Item models."""
530
+ return [Item.model_validate(item) for item in items_data]
531
+
532
+ def _deserialize_search_result(
533
+ self,
534
+ search_result_data: dict[str, Any],
535
+ ) -> SearchResult:
536
+ """Deserialize search result data from API response to SearchResult model."""
537
+ return SearchResult.model_validate(search_result_data)
538
+
539
+ def _deserialize_variations_result(
540
+ self,
541
+ variations_result_data: dict[str, Any],
542
+ ) -> VariationsResult:
543
+ """Deserialize variations data from API response to VariationsResult model."""
544
+ return VariationsResult.model_validate(variations_result_data)
545
+
546
+ def _deserialize_browse_nodes(
547
+ self,
548
+ browse_nodes_data: list[dict[str, Any]],
549
+ ) -> list[BrowseNode]:
550
+ """Deserialize browse nodes data from API response to BrowseNode models."""
551
+ return [BrowseNode.model_validate(node) for node in browse_nodes_data]