amazon-creators-async 0.1.0__tar.gz
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.
- amazon_creators_async-0.1.0/.github/workflows/publish.yml +34 -0
- amazon_creators_async-0.1.0/.github/workflows/tests.yml +41 -0
- amazon_creators_async-0.1.0/.gitignore +48 -0
- amazon_creators_async-0.1.0/CHANGELOG.md +17 -0
- amazon_creators_async-0.1.0/CONTRIBUTING.md +56 -0
- amazon_creators_async-0.1.0/LICENSE +21 -0
- amazon_creators_async-0.1.0/PKG-INFO +140 -0
- amazon_creators_async-0.1.0/QUICK_START.md +98 -0
- amazon_creators_async-0.1.0/README.md +108 -0
- amazon_creators_async-0.1.0/amazon_creators_async/__init__.py +17 -0
- amazon_creators_async-0.1.0/amazon_creators_async/auth.py +105 -0
- amazon_creators_async-0.1.0/amazon_creators_async/client.py +195 -0
- amazon_creators_async-0.1.0/amazon_creators_async/exceptions.py +23 -0
- amazon_creators_async-0.1.0/amazon_creators_async/limiter.py +33 -0
- amazon_creators_async-0.1.0/amazon_creators_async/models/__init__.py +50 -0
- amazon_creators_async-0.1.0/amazon_creators_async/models/requests.py +90 -0
- amazon_creators_async-0.1.0/amazon_creators_async/models/responses.py +98 -0
- amazon_creators_async-0.1.0/amazon_creators_async/utils.py +51 -0
- amazon_creators_async-0.1.0/pyproject.toml +50 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Upload Python Package to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
deploy:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Set up Python
|
|
18
|
+
uses: actions/setup-python@v4
|
|
19
|
+
with:
|
|
20
|
+
python-version: '3.12'
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: |
|
|
24
|
+
python -m pip install --upgrade pip
|
|
25
|
+
pip install build twine
|
|
26
|
+
|
|
27
|
+
- name: Build package
|
|
28
|
+
run: python -m build
|
|
29
|
+
|
|
30
|
+
- name: Publish package
|
|
31
|
+
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
|
|
32
|
+
with:
|
|
33
|
+
user: __token__
|
|
34
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Python package test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ "master", "main" ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ "master", "main" ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v4
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade pip
|
|
27
|
+
python -m pip install flake8 pytest pytest-asyncio
|
|
28
|
+
if [ -f pyproject.toml ]; then pip install -e .; fi
|
|
29
|
+
|
|
30
|
+
- name: Lint with flake8
|
|
31
|
+
run: |
|
|
32
|
+
# stop the build if there are Python syntax errors or undefined names
|
|
33
|
+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
34
|
+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
|
35
|
+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
|
36
|
+
|
|
37
|
+
# We will skip direct pytest here as we need live credentials for integration testing,
|
|
38
|
+
# or you can implement mock-based unittests later.
|
|
39
|
+
- name: Project Install Verification
|
|
40
|
+
run: |
|
|
41
|
+
python -c "import amazon-creators-async; print(amazon-creators-async.__version__)"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Environments
|
|
2
|
+
.env
|
|
3
|
+
venv/
|
|
4
|
+
.venv/
|
|
5
|
+
env/
|
|
6
|
+
ENV/
|
|
7
|
+
|
|
8
|
+
# Python
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*$py.class
|
|
12
|
+
*.so
|
|
13
|
+
.Python
|
|
14
|
+
build/
|
|
15
|
+
develop-eggs/
|
|
16
|
+
dist/
|
|
17
|
+
downloads/
|
|
18
|
+
eggs/
|
|
19
|
+
.eggs/
|
|
20
|
+
lib/
|
|
21
|
+
lib64/
|
|
22
|
+
parts/
|
|
23
|
+
sdist/
|
|
24
|
+
var/
|
|
25
|
+
wheels/
|
|
26
|
+
share/python-wheels/
|
|
27
|
+
*.egg-info/
|
|
28
|
+
.installed.cfg
|
|
29
|
+
*.egg
|
|
30
|
+
MANIFEST
|
|
31
|
+
|
|
32
|
+
# Test scripts (local usage only)
|
|
33
|
+
test_*.py
|
|
34
|
+
|
|
35
|
+
# IDEs and Editors
|
|
36
|
+
.vscode/
|
|
37
|
+
.idea/
|
|
38
|
+
*.swp
|
|
39
|
+
*.swo
|
|
40
|
+
|
|
41
|
+
# OS generated files
|
|
42
|
+
.DS_Store
|
|
43
|
+
.DS_Store?
|
|
44
|
+
._*
|
|
45
|
+
.Spotlight-V100
|
|
46
|
+
.Trashes
|
|
47
|
+
ehthumbs.db
|
|
48
|
+
Thumbs.db
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-03-05
|
|
9
|
+
### Added
|
|
10
|
+
- Initial release of the `amazon-creators-async` wrapper.
|
|
11
|
+
- Full OAuth 2.0 support (Cognito v2.x and LWA v3.x credentials).
|
|
12
|
+
- Native asynchronous core leveraging `httpx`.
|
|
13
|
+
- Configurable Rate Limiting defaulting to 1 TPS via `aiolimiter`.
|
|
14
|
+
- Complete Pydantic v2 models mapping Python `snake_case` to Amazon API `lowerCamelCase`.
|
|
15
|
+
- Endpoints: `search_items`, `get_items`, `get_browse_nodes`, `get_variations`.
|
|
16
|
+
- PyPI release metadata (`pyproject.toml`).
|
|
17
|
+
- Examples and documentation (README, QUICK_START, CONTRIBUTING).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Contributing to amazon-creators-async
|
|
2
|
+
|
|
3
|
+
Thank you for your interest in contributing! This project follows the same open-source model as `aliexpress-async-api`.
|
|
4
|
+
|
|
5
|
+
## 🛠 Prerequisites
|
|
6
|
+
|
|
7
|
+
1. **Python 3.8+**
|
|
8
|
+
2. **Amazon Associate & Creators API Credentials**: You need a `credential_id`, `credential_secret`, and `partner_tag` to run the live tests.
|
|
9
|
+
|
|
10
|
+
## 🚀 Setting Up Your Development Environment
|
|
11
|
+
|
|
12
|
+
1. **Fork** the repository and clone your fork:
|
|
13
|
+
```bash
|
|
14
|
+
git clone https://github.com/yourusername/amazon-creators-async.git
|
|
15
|
+
cd amazon-creators-async
|
|
16
|
+
```
|
|
17
|
+
2. **Create a virtual environment**:
|
|
18
|
+
```bash
|
|
19
|
+
python -m venv .venv
|
|
20
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
21
|
+
```
|
|
22
|
+
3. **Install dependencies in editable mode**:
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 🧪 Testing
|
|
28
|
+
|
|
29
|
+
We use the provided `test_client.py` and `test_extra_endpoints.py` to validate API responses directly against Amazon's servers.
|
|
30
|
+
|
|
31
|
+
1. Create a `.env` file in the root directory:
|
|
32
|
+
```ini
|
|
33
|
+
AMAZON_CREDENTIAL_ID="amzn1.application-oa2-client..."
|
|
34
|
+
AMAZON_CREDENTIAL_SECRET="amzn1.oa2-cs.v1..."
|
|
35
|
+
AMAZON_PARTNER_TAG="yourtag-20"
|
|
36
|
+
AMAZON_VERSION="3.1"
|
|
37
|
+
```
|
|
38
|
+
2. Run the tests:
|
|
39
|
+
```bash
|
|
40
|
+
python test_client.py
|
|
41
|
+
python test_extra_endpoints.py
|
|
42
|
+
```
|
|
43
|
+
3. Ensure your code doesn't produce `400 Invalid Request` or `429 TooManyRequests`.
|
|
44
|
+
|
|
45
|
+
## 📌 Coding Guidelines
|
|
46
|
+
|
|
47
|
+
* **Asynchronous First**: All network operations must remain asynchronous using `httpx`.
|
|
48
|
+
* **Pydantic Consistency**: Always map native Amazon `lowerCamelCase` responses to Pythonic `snake_case` using Pydantic `ConfigDict` and `alias_generator`. Do not leak `CamelCase` variables into the public Python signatures.
|
|
49
|
+
* **Rate Limits**: Do not bypass or hardcode modifications to the `RateLimiter` unless introducing a documented generic feature (like supporting Amazon's tiered TPS).
|
|
50
|
+
|
|
51
|
+
## 📄 Pull Request Process
|
|
52
|
+
|
|
53
|
+
1. Create a feature branch (`git checkout -b feature/awesome-new-feature`).
|
|
54
|
+
2. Commit your changes following [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
55
|
+
3. Push your branch (`git push origin feature/awesome-new-feature`).
|
|
56
|
+
4. Open a Pull Request on GitHub.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Igor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amazon-creators-async
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A modern, high-performance asynchronous Python wrapper for the new Amazon Creators API (OAuth 2.0 based).
|
|
5
|
+
Project-URL: Homepage, https://github.com/ils15/amazon-creators-async
|
|
6
|
+
Project-URL: Repository, https://github.com/ils15/amazon-creators-async.git
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/ils15/amazon-creators-async/issues
|
|
8
|
+
Author-email: Igor <igor@example.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: affiliate,amazon,api,async,creators,httpx,oauth2
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Requires-Dist: aiolimiter>=1.1.0
|
|
26
|
+
Requires-Dist: httpx>=0.24.0
|
|
27
|
+
Requires-Dist: pydantic>=2.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Async Amazon Creators API (OAuth 2.0)
|
|
34
|
+
|
|
35
|
+
[](https://badge.fury.io/py/amazon-creators-async)
|
|
36
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
[](https://pypi.org/project/amazon-creators-async/)
|
|
38
|
+
|
|
39
|
+
A modern, high-performance, asynchronous Python wrapper for the **Amazon Creators API** (the replacement for the old Product Advertising API 5.0).
|
|
40
|
+
|
|
41
|
+
It fully supports the new **OAuth 2.0** authentication flow, including `v3.1` (Login with Amazon - LWA) credentials, and handles the strict rate limitations automatically to protect your account.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- ⚡️ **Fully Asynchronous**: Built directly on `httpx` and `asyncio` for maximum performance.
|
|
46
|
+
- 🔐 **OAuth 2.0 Native**: Automatic token fetching, caching, and renewal before expiration. Supports Cognito (v2.x) and LWA (v3.x).
|
|
47
|
+
- 🚦 **Built-in Rate Limiting**: Uses `aiolimiter` to ensure you never exceed Amazon's 1 TPS (Transactions Per Second) limit by default, preventing `429 TooManyRequests` bans.
|
|
48
|
+
- 📦 **Pydantic Validation**: Full request and response validation using Pydantic v2.
|
|
49
|
+
- 🐪 **Auto CamelCase mapping**: You write Pythonic `snake_case`, the library talks to Amazon in their required `lowerCamelCase`.
|
|
50
|
+
- 🛠️ **Full API Coverage**: `SearchItems`, `GetItems`, `GetBrowseNodes`, and `GetVariations`.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install amazon-creators-async
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import asyncio
|
|
62
|
+
from amazon_creators_async import AmazonCreatorsAsyncClient, Region
|
|
63
|
+
|
|
64
|
+
async def main():
|
|
65
|
+
async with AmazonCreatorsAsyncClient(
|
|
66
|
+
credential_id="YOUR_APP_ID",
|
|
67
|
+
credential_secret="YOUR_APP_SECRET",
|
|
68
|
+
partner_tag="yourtag-20", # Your Amazon Associate tag
|
|
69
|
+
marketplace="www.amazon.com", # Target marketplace domain
|
|
70
|
+
region=Region.NORTH_AMERICA, # Standard region (includes Brazil)
|
|
71
|
+
version="3.1" # Version from your Creator Console
|
|
72
|
+
) as client:
|
|
73
|
+
|
|
74
|
+
# 1. Search for products
|
|
75
|
+
search_res = await client.search_items(
|
|
76
|
+
keywords="Mechanical Keyboard",
|
|
77
|
+
item_count=5, # Number of items to return
|
|
78
|
+
resources=[
|
|
79
|
+
"itemInfo.title",
|
|
80
|
+
"offersV2.listings.price",
|
|
81
|
+
"images.primary.large"
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
for item in search_res.search_result.items:
|
|
86
|
+
print(f"ASIN: {item.asin}")
|
|
87
|
+
if item.item_info and item.item_info.title:
|
|
88
|
+
print(f"Title: {item.item_info.title.get('displayValue')}")
|
|
89
|
+
|
|
90
|
+
print("-" * 20)
|
|
91
|
+
|
|
92
|
+
# 2. Get specific items by ASIN
|
|
93
|
+
get_res = await client.get_items(
|
|
94
|
+
item_ids=["B07XQXZXJC", "B08FJMVZX6"],
|
|
95
|
+
resources=["itemInfo.features"]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for item in get_res.items_result.items:
|
|
99
|
+
print(f"Features for {item.asin}:")
|
|
100
|
+
if item.item_info and item.item_info.features:
|
|
101
|
+
for feature in item.item_info.features.get('displayValues', []):
|
|
102
|
+
print(f" - {feature}")
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
asyncio.run(main())
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Available Resources
|
|
109
|
+
The Amazon API requires you to specify the `resources` you want returned to minimize payload size. We use the updated **lowerCamelCase** format required by v3.x of the API.
|
|
110
|
+
Common Examples:
|
|
111
|
+
- `itemInfo.title`
|
|
112
|
+
- `offersV2.listings.price`
|
|
113
|
+
- `images.primary.small`
|
|
114
|
+
- `images.variants.large`
|
|
115
|
+
- `itemInfo.features`
|
|
116
|
+
- `browseNodeInfo.browseNodes`
|
|
117
|
+
|
|
118
|
+
## Rate Limiting (Throttling)
|
|
119
|
+
|
|
120
|
+
Amazon imposes strict API limitations. Exceeding them regularly can lead to your account being blocked.
|
|
121
|
+
* **Default Limit**: 1 request per second (TPS).
|
|
122
|
+
* **Library Behavior**: The `AmazonCreatorsAsyncClient` handles this automatically. If you fire 10 requests at once using `asyncio.gather()`, the built-in limiter will process exactly 1 per second.
|
|
123
|
+
* **Custom Limits**: If your account has a higher tier limit, you can adjust the TPS:
|
|
124
|
+
```python
|
|
125
|
+
AmazonCreatorsAsyncClient(..., rate_limit_tps=5.0) # For accounts with 5 TPS
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Authentication Versions
|
|
129
|
+
|
|
130
|
+
The library automatically negotiates endpoints based on the `version` passed during initialization:
|
|
131
|
+
- **v2.x (e.g., "2.1")**: Uses Cognito endpoints with Basic Auth forms.
|
|
132
|
+
- **v3.x (e.g., "3.1")**: Uses Login with Amazon (LWA) endpoints with JSON bodies.
|
|
133
|
+
|
|
134
|
+
## Documentation
|
|
135
|
+
- [Amazon Creators API Official Docs](https://affiliate-program.amazon.com/creatorsapi/docs/en-us/introduction)
|
|
136
|
+
- [Quick Start Guide](QUICK_START.md)
|
|
137
|
+
- [Contributing](CONTRIBUTING.md)
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Quick Start: amazon-creators-async
|
|
2
|
+
|
|
3
|
+
Get up and running with the Amazon Creators API in 5 minutes!
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
* An approved **Amazon Associates** account.
|
|
7
|
+
* A registered application in the **Amazon Creators Console**.
|
|
8
|
+
* Your `credential_id`, `credential_secret`, and `partner_tag`.
|
|
9
|
+
|
|
10
|
+
## 1. Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install amazon-creators-async
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 2. Basic Setup (FastAPI Example)
|
|
17
|
+
|
|
18
|
+
Here is how you would use this library inside a modern async framework like FastAPI to prevent thread-blocking while waiting for Amazon's API:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
from amazon_creators_async import AmazonCreatorsAsyncClient, Region
|
|
23
|
+
|
|
24
|
+
app = FastAPI()
|
|
25
|
+
|
|
26
|
+
# Usually, you'd load these from environment variables (.env)
|
|
27
|
+
CREATOR_ID = "amzn1.application..."
|
|
28
|
+
CREATOR_SECRET = "amzn1.oa2-cs..."
|
|
29
|
+
PARTNER_TAG = "mywebsite-20"
|
|
30
|
+
|
|
31
|
+
# Note: In production, instantiate this client once on startup (Lifespan event)
|
|
32
|
+
# and share it across requests to reuse the connection pool and rate limiter.
|
|
33
|
+
client = AmazonCreatorsAsyncClient(
|
|
34
|
+
credential_id=CREATOR_ID,
|
|
35
|
+
credential_secret=CREATOR_SECRET,
|
|
36
|
+
partner_tag=PARTNER_TAG,
|
|
37
|
+
marketplace="www.amazon.com",
|
|
38
|
+
region=Region.NORTH_AMERICA,
|
|
39
|
+
version="3.1" # Find your version in the Creators Console
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@app.get("/search/{keyword}")
|
|
43
|
+
async def search_amazon(keyword: str):
|
|
44
|
+
response = await client.search_items(
|
|
45
|
+
keywords=keyword,
|
|
46
|
+
item_count=5,
|
|
47
|
+
resources=[
|
|
48
|
+
"itemInfo.title",
|
|
49
|
+
"offersV2.listings.price",
|
|
50
|
+
"images.primary.large"
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Map the response into a clean JSON output
|
|
55
|
+
results = []
|
|
56
|
+
for item in response.search_result.items:
|
|
57
|
+
title = item.item_info.title.get('displayValue', 'No title') if item.item_info and item.item_info.title else "No title"
|
|
58
|
+
|
|
59
|
+
price = "Out of Stock"
|
|
60
|
+
if item.offers_v2 and item.offers_v2.listings:
|
|
61
|
+
price = item.offers_v2.listings[0].price.display_amount
|
|
62
|
+
|
|
63
|
+
results.append({
|
|
64
|
+
"asin": item.asin,
|
|
65
|
+
"title": title,
|
|
66
|
+
"price": price
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return {"results": results}
|
|
70
|
+
|
|
71
|
+
@app.on_event("shutdown")
|
|
72
|
+
async def shutdown_event():
|
|
73
|
+
# Always close the client cleanly
|
|
74
|
+
await client.close()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 3. Parallel Execution (Safe Throttling)
|
|
78
|
+
|
|
79
|
+
If you need to query multiple distinct ASINs or categories at once, you can safely use `asyncio.gather`.
|
|
80
|
+
Our built-in `RateLimiter` ensures you don't violate the 1 TPS limit:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
|
|
85
|
+
async def parallel_fetch():
|
|
86
|
+
# Assuming `client` is already initialized
|
|
87
|
+
|
|
88
|
+
# These three calls are fired immediately
|
|
89
|
+
tasks = [
|
|
90
|
+
client.get_items(item_ids=["B07XQXZXJC"], resources=["itemInfo.title"]),
|
|
91
|
+
client.get_items(item_ids=["B08FJMVZX6"], resources=["itemInfo.title"]),
|
|
92
|
+
client.get_items(item_ids=["B09B8VGCR8"], resources=["itemInfo.title"])
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
# The client will space them out (1 request per second) automatically!
|
|
96
|
+
results = await asyncio.gather(*tasks)
|
|
97
|
+
return results
|
|
98
|
+
```
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Async Amazon Creators API (OAuth 2.0)
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/py/amazon-creators-async)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://pypi.org/project/amazon-creators-async/)
|
|
6
|
+
|
|
7
|
+
A modern, high-performance, asynchronous Python wrapper for the **Amazon Creators API** (the replacement for the old Product Advertising API 5.0).
|
|
8
|
+
|
|
9
|
+
It fully supports the new **OAuth 2.0** authentication flow, including `v3.1` (Login with Amazon - LWA) credentials, and handles the strict rate limitations automatically to protect your account.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- ⚡️ **Fully Asynchronous**: Built directly on `httpx` and `asyncio` for maximum performance.
|
|
14
|
+
- 🔐 **OAuth 2.0 Native**: Automatic token fetching, caching, and renewal before expiration. Supports Cognito (v2.x) and LWA (v3.x).
|
|
15
|
+
- 🚦 **Built-in Rate Limiting**: Uses `aiolimiter` to ensure you never exceed Amazon's 1 TPS (Transactions Per Second) limit by default, preventing `429 TooManyRequests` bans.
|
|
16
|
+
- 📦 **Pydantic Validation**: Full request and response validation using Pydantic v2.
|
|
17
|
+
- 🐪 **Auto CamelCase mapping**: You write Pythonic `snake_case`, the library talks to Amazon in their required `lowerCamelCase`.
|
|
18
|
+
- 🛠️ **Full API Coverage**: `SearchItems`, `GetItems`, `GetBrowseNodes`, and `GetVariations`.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install amazon-creators-async
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
from amazon_creators_async import AmazonCreatorsAsyncClient, Region
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
async with AmazonCreatorsAsyncClient(
|
|
34
|
+
credential_id="YOUR_APP_ID",
|
|
35
|
+
credential_secret="YOUR_APP_SECRET",
|
|
36
|
+
partner_tag="yourtag-20", # Your Amazon Associate tag
|
|
37
|
+
marketplace="www.amazon.com", # Target marketplace domain
|
|
38
|
+
region=Region.NORTH_AMERICA, # Standard region (includes Brazil)
|
|
39
|
+
version="3.1" # Version from your Creator Console
|
|
40
|
+
) as client:
|
|
41
|
+
|
|
42
|
+
# 1. Search for products
|
|
43
|
+
search_res = await client.search_items(
|
|
44
|
+
keywords="Mechanical Keyboard",
|
|
45
|
+
item_count=5, # Number of items to return
|
|
46
|
+
resources=[
|
|
47
|
+
"itemInfo.title",
|
|
48
|
+
"offersV2.listings.price",
|
|
49
|
+
"images.primary.large"
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
for item in search_res.search_result.items:
|
|
54
|
+
print(f"ASIN: {item.asin}")
|
|
55
|
+
if item.item_info and item.item_info.title:
|
|
56
|
+
print(f"Title: {item.item_info.title.get('displayValue')}")
|
|
57
|
+
|
|
58
|
+
print("-" * 20)
|
|
59
|
+
|
|
60
|
+
# 2. Get specific items by ASIN
|
|
61
|
+
get_res = await client.get_items(
|
|
62
|
+
item_ids=["B07XQXZXJC", "B08FJMVZX6"],
|
|
63
|
+
resources=["itemInfo.features"]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
for item in get_res.items_result.items:
|
|
67
|
+
print(f"Features for {item.asin}:")
|
|
68
|
+
if item.item_info and item.item_info.features:
|
|
69
|
+
for feature in item.item_info.features.get('displayValues', []):
|
|
70
|
+
print(f" - {feature}")
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
asyncio.run(main())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Available Resources
|
|
77
|
+
The Amazon API requires you to specify the `resources` you want returned to minimize payload size. We use the updated **lowerCamelCase** format required by v3.x of the API.
|
|
78
|
+
Common Examples:
|
|
79
|
+
- `itemInfo.title`
|
|
80
|
+
- `offersV2.listings.price`
|
|
81
|
+
- `images.primary.small`
|
|
82
|
+
- `images.variants.large`
|
|
83
|
+
- `itemInfo.features`
|
|
84
|
+
- `browseNodeInfo.browseNodes`
|
|
85
|
+
|
|
86
|
+
## Rate Limiting (Throttling)
|
|
87
|
+
|
|
88
|
+
Amazon imposes strict API limitations. Exceeding them regularly can lead to your account being blocked.
|
|
89
|
+
* **Default Limit**: 1 request per second (TPS).
|
|
90
|
+
* **Library Behavior**: The `AmazonCreatorsAsyncClient` handles this automatically. If you fire 10 requests at once using `asyncio.gather()`, the built-in limiter will process exactly 1 per second.
|
|
91
|
+
* **Custom Limits**: If your account has a higher tier limit, you can adjust the TPS:
|
|
92
|
+
```python
|
|
93
|
+
AmazonCreatorsAsyncClient(..., rate_limit_tps=5.0) # For accounts with 5 TPS
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Authentication Versions
|
|
97
|
+
|
|
98
|
+
The library automatically negotiates endpoints based on the `version` passed during initialization:
|
|
99
|
+
- **v2.x (e.g., "2.1")**: Uses Cognito endpoints with Basic Auth forms.
|
|
100
|
+
- **v3.x (e.g., "3.1")**: Uses Login with Amazon (LWA) endpoints with JSON bodies.
|
|
101
|
+
|
|
102
|
+
## Documentation
|
|
103
|
+
- [Amazon Creators API Official Docs](https://affiliate-program.amazon.com/creatorsapi/docs/en-us/introduction)
|
|
104
|
+
- [Quick Start Guide](QUICK_START.md)
|
|
105
|
+
- [Contributing](CONTRIBUTING.md)
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .client import AmazonCreatorsAsyncClient
|
|
2
|
+
from .exceptions import AmazonCreatorsException, RateLimitError, AuthenticationError
|
|
3
|
+
from .models.requests import SearchItemsRequest, GetItemsRequest
|
|
4
|
+
from .models.responses import SearchItemsResponse, GetItemsResponse
|
|
5
|
+
from .utils import Region
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AmazonCreatorsAsyncClient",
|
|
9
|
+
"AmazonCreatorsException",
|
|
10
|
+
"RateLimitError",
|
|
11
|
+
"AuthenticationError",
|
|
12
|
+
"SearchItemsRequest",
|
|
13
|
+
"GetItemsRequest",
|
|
14
|
+
"SearchItemsResponse",
|
|
15
|
+
"GetItemsResponse",
|
|
16
|
+
"Region"
|
|
17
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import time
|
|
3
|
+
import asyncio
|
|
4
|
+
import httpx
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .exceptions import AuthenticationError
|
|
8
|
+
from .utils import get_auth_endpoint, get_scope
|
|
9
|
+
|
|
10
|
+
class AuthManager:
|
|
11
|
+
"""
|
|
12
|
+
Manages OAuth 2.0 Client Credentials token fetching and caching
|
|
13
|
+
for the Amazon Creators API. Supports both Cognito (v2.x) and LWA (v3.x).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
credential_id: str,
|
|
19
|
+
credential_secret: str,
|
|
20
|
+
version: str,
|
|
21
|
+
client: Optional[httpx.AsyncClient] = None
|
|
22
|
+
):
|
|
23
|
+
self.credential_id = credential_id
|
|
24
|
+
self.credential_secret = credential_secret
|
|
25
|
+
self.version = version
|
|
26
|
+
|
|
27
|
+
# Determine endpoints and scopes
|
|
28
|
+
self.auth_url = get_auth_endpoint(version)
|
|
29
|
+
self.scope = get_scope(version)
|
|
30
|
+
|
|
31
|
+
# Pass a client if you want to share the connection pool, otherwise we manage our own
|
|
32
|
+
self._client = client or httpx.AsyncClient(timeout=30.0)
|
|
33
|
+
self._owns_client = client is None
|
|
34
|
+
self._token_lock = asyncio.Lock()
|
|
35
|
+
|
|
36
|
+
# State for token caching
|
|
37
|
+
self._access_token: Optional[str] = None
|
|
38
|
+
self._token_expires_at: float = 0.0
|
|
39
|
+
|
|
40
|
+
async def get_valid_token(self) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Returns a valid OAuth 2.0 access token.
|
|
43
|
+
If the current token is missing or expired (with a 60s buffer), it fetches a new one.
|
|
44
|
+
"""
|
|
45
|
+
if self._access_token and time.time() < (self._token_expires_at - 60):
|
|
46
|
+
return self._access_token
|
|
47
|
+
|
|
48
|
+
# Avoid parallel refreshes from concurrent API calls.
|
|
49
|
+
async with self._token_lock:
|
|
50
|
+
if self._access_token and time.time() < (self._token_expires_at - 60):
|
|
51
|
+
return self._access_token
|
|
52
|
+
return await self._fetch_new_token()
|
|
53
|
+
|
|
54
|
+
async def _fetch_new_token(self) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Performs the HTTP call to the auth endpoint to get the token.
|
|
57
|
+
Handles both Cognito (form-encoded) and LWA (JSON) versions.
|
|
58
|
+
"""
|
|
59
|
+
is_lwa = self.version.startswith("3.")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
if is_lwa:
|
|
63
|
+
# LWA (v3.1+) uses JSON body with credentials inside
|
|
64
|
+
headers = {"Content-Type": "application/json"}
|
|
65
|
+
payload = {
|
|
66
|
+
"grant_type": "client_credentials",
|
|
67
|
+
"client_id": self.credential_id,
|
|
68
|
+
"client_secret": self.credential_secret,
|
|
69
|
+
"scope": self.scope
|
|
70
|
+
}
|
|
71
|
+
response = await self._client.post(self.auth_url, headers=headers, json=payload)
|
|
72
|
+
else:
|
|
73
|
+
# Cognito (v2.x) uses Basic Auth + form-encoded data
|
|
74
|
+
auth_string = f"{self.credential_id}:{self.credential_secret}"
|
|
75
|
+
encoded_auth = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8")
|
|
76
|
+
headers = {
|
|
77
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
78
|
+
"Authorization": f"Basic {encoded_auth}"
|
|
79
|
+
}
|
|
80
|
+
data = {
|
|
81
|
+
"grant_type": "client_credentials",
|
|
82
|
+
"scope": self.scope
|
|
83
|
+
}
|
|
84
|
+
response = await self._client.post(self.auth_url, headers=headers, data=data)
|
|
85
|
+
|
|
86
|
+
if response.status_code != 200:
|
|
87
|
+
raise AuthenticationError(
|
|
88
|
+
f"Failed to obtain token ({self.version}). Status: {response.status_code}. Response: {response.text}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
payload_data = response.json()
|
|
92
|
+
self._access_token = payload_data["access_token"]
|
|
93
|
+
|
|
94
|
+
# Usually expires in 3600 seconds (1 hour)
|
|
95
|
+
expires_in = payload_data.get("expires_in", 3600)
|
|
96
|
+
self._token_expires_at = time.time() + expires_in
|
|
97
|
+
|
|
98
|
+
return self._access_token
|
|
99
|
+
except httpx.RequestError as exc:
|
|
100
|
+
raise AuthenticationError(f"HTTP error occurred while requesting auth token: {exc}") from exc
|
|
101
|
+
|
|
102
|
+
async def close(self):
|
|
103
|
+
"""Close internally managed HTTP client."""
|
|
104
|
+
if self._owns_client:
|
|
105
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import Optional, Dict, Any, List
|
|
4
|
+
|
|
5
|
+
from .auth import AuthManager
|
|
6
|
+
from .limiter import RateLimiter
|
|
7
|
+
from .utils import Region, get_api_endpoint, get_version_for_region, validate_marketplace
|
|
8
|
+
from .exceptions import RateLimitError, InvalidRequestError, APIError
|
|
9
|
+
from .models.requests import SearchItemsRequest, GetItemsRequest, GetBrowseNodesRequest, GetVariationsRequest
|
|
10
|
+
from .models.responses import SearchItemsResponse, GetItemsResponse, GetBrowseNodesResponse, GetVariationsResponse
|
|
11
|
+
|
|
12
|
+
class AmazonCreatorsAsyncClient:
|
|
13
|
+
"""
|
|
14
|
+
Asynchronous client for the Amazon Creators API.
|
|
15
|
+
Handles OAuth 2.0 authentication, rate limiting, and parameter serialization.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
credential_id: str,
|
|
21
|
+
credential_secret: str,
|
|
22
|
+
marketplace: str = "www.amazon.com.br",
|
|
23
|
+
partner_tag: str = "",
|
|
24
|
+
region: Region = Region.NORTH_AMERICA,
|
|
25
|
+
version: Optional[str] = None, # Allow explicit version (e.g. 3.1)
|
|
26
|
+
rate_limit_tps: float = 1.0,
|
|
27
|
+
max_retries: int = 2,
|
|
28
|
+
retry_backoff_seconds: float = 0.5,
|
|
29
|
+
client: Optional[httpx.AsyncClient] = None
|
|
30
|
+
):
|
|
31
|
+
validate_marketplace(marketplace)
|
|
32
|
+
if not partner_tag:
|
|
33
|
+
raise ValueError("partner_tag is required.")
|
|
34
|
+
|
|
35
|
+
self.credential_id = credential_id
|
|
36
|
+
self.credential_secret = credential_secret
|
|
37
|
+
self.marketplace = marketplace
|
|
38
|
+
self.partner_tag = partner_tag
|
|
39
|
+
self.region = region
|
|
40
|
+
self.api_version = version or get_version_for_region(region)
|
|
41
|
+
self.endpoint_url = get_api_endpoint(region)
|
|
42
|
+
self.max_retries = max(0, int(max_retries))
|
|
43
|
+
self.retry_backoff_seconds = max(0.1, float(retry_backoff_seconds))
|
|
44
|
+
|
|
45
|
+
# Async HTTP client
|
|
46
|
+
self._client = client or httpx.AsyncClient(timeout=30.0)
|
|
47
|
+
self._owns_client = client is None
|
|
48
|
+
|
|
49
|
+
# Managers
|
|
50
|
+
self._auth_manager = AuthManager(
|
|
51
|
+
credential_id=self.credential_id,
|
|
52
|
+
credential_secret=self.credential_secret,
|
|
53
|
+
version=self.api_version,
|
|
54
|
+
client=self._client
|
|
55
|
+
)
|
|
56
|
+
self._limiter = RateLimiter(tps=rate_limit_tps)
|
|
57
|
+
|
|
58
|
+
async def _request(self, operation: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
59
|
+
"""Core method to dispatch a request to the Amazon API with Auth & Limiter."""
|
|
60
|
+
url = f"{self.endpoint_url}/{operation}"
|
|
61
|
+
|
|
62
|
+
for attempt in range(self.max_retries + 1):
|
|
63
|
+
# 1. Wait for Rate Limiter Capacity.
|
|
64
|
+
await self._limiter.acquire()
|
|
65
|
+
|
|
66
|
+
# 2. Get a valid OAuth2 Token (cached or renewed).
|
|
67
|
+
token = await self._auth_manager.get_valid_token()
|
|
68
|
+
|
|
69
|
+
# 3. Prepare headers.
|
|
70
|
+
if self.api_version.startswith("3."):
|
|
71
|
+
auth_header = f"Bearer {token}"
|
|
72
|
+
else:
|
|
73
|
+
auth_header = f"Bearer {token}, Version {self.api_version}"
|
|
74
|
+
|
|
75
|
+
headers = {
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
"Authorization": auth_header,
|
|
78
|
+
"x-marketplace": self.marketplace,
|
|
79
|
+
"User-Agent": "amazon_creators_async/0.1.0",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
response = await self._client.post(url, json=payload, headers=headers)
|
|
84
|
+
except httpx.RequestError as exc:
|
|
85
|
+
if attempt >= self.max_retries:
|
|
86
|
+
raise APIError(f"Network error: {exc}") from exc
|
|
87
|
+
await asyncio.sleep(self.retry_backoff_seconds * (2 ** attempt))
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if response.status_code == 200:
|
|
91
|
+
return response.json()
|
|
92
|
+
|
|
93
|
+
if response.status_code == 429:
|
|
94
|
+
if attempt >= self.max_retries:
|
|
95
|
+
raise RateLimitError(f"Rate limit exceeded: {response.text}")
|
|
96
|
+
retry_after = response.headers.get("Retry-After")
|
|
97
|
+
delay = self.retry_backoff_seconds * (2 ** attempt)
|
|
98
|
+
if retry_after:
|
|
99
|
+
try:
|
|
100
|
+
delay = max(0.0, float(retry_after))
|
|
101
|
+
except ValueError:
|
|
102
|
+
pass
|
|
103
|
+
await asyncio.sleep(delay)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if response.status_code == 400:
|
|
107
|
+
raise InvalidRequestError(f"Invalid request: {response.text}")
|
|
108
|
+
|
|
109
|
+
if response.status_code in {500, 502, 503, 504} and attempt < self.max_retries:
|
|
110
|
+
await asyncio.sleep(self.retry_backoff_seconds * (2 ** attempt))
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
raise APIError(
|
|
114
|
+
f"API Error ({response.status_code}): {response.text}",
|
|
115
|
+
status_code=response.status_code,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
raise APIError("Unexpected request retry flow termination")
|
|
119
|
+
|
|
120
|
+
async def search_items(self, **kwargs) -> SearchItemsResponse:
|
|
121
|
+
"""
|
|
122
|
+
Search for items on Amazon using keywords, browse_node_id, brand, etc.
|
|
123
|
+
"""
|
|
124
|
+
# Inject defaults if missing
|
|
125
|
+
kwargs.setdefault("marketplace", self.marketplace)
|
|
126
|
+
kwargs.setdefault("partner_tag", self.partner_tag)
|
|
127
|
+
|
|
128
|
+
# Validate through Pydantic model
|
|
129
|
+
request_obj = SearchItemsRequest(**kwargs)
|
|
130
|
+
|
|
131
|
+
# Export forcing lowerCamelCase format and removing nulls
|
|
132
|
+
payload = request_obj.model_dump(by_alias=True, exclude_none=True)
|
|
133
|
+
|
|
134
|
+
data = await self._request("searchItems", payload)
|
|
135
|
+
return SearchItemsResponse(**data)
|
|
136
|
+
|
|
137
|
+
async def get_items(self, item_ids: List[str], **kwargs) -> GetItemsResponse:
|
|
138
|
+
"""
|
|
139
|
+
Get detailed item information using a batch of ASINs (ItemIds).
|
|
140
|
+
"""
|
|
141
|
+
if not item_ids:
|
|
142
|
+
raise ValueError("item_ids must contain at least one ASIN")
|
|
143
|
+
|
|
144
|
+
kwargs.setdefault("marketplace", self.marketplace)
|
|
145
|
+
kwargs.setdefault("partner_tag", self.partner_tag)
|
|
146
|
+
kwargs["item_ids"] = item_ids
|
|
147
|
+
|
|
148
|
+
# Validate through Pydantic model
|
|
149
|
+
request_obj = GetItemsRequest(**kwargs)
|
|
150
|
+
|
|
151
|
+
# Export forcing lowerCamelCase format and removing nulls
|
|
152
|
+
payload = request_obj.model_dump(by_alias=True, exclude_none=True)
|
|
153
|
+
|
|
154
|
+
data = await self._request("getItems", payload)
|
|
155
|
+
return GetItemsResponse(**data)
|
|
156
|
+
|
|
157
|
+
async def get_browse_nodes(self, browse_node_ids: List[str], **kwargs) -> GetBrowseNodesResponse:
|
|
158
|
+
"""
|
|
159
|
+
Get Browse Node details (categories geometry) on Amazon using their IDs.
|
|
160
|
+
"""
|
|
161
|
+
kwargs.setdefault("marketplace", self.marketplace)
|
|
162
|
+
kwargs.setdefault("partner_tag", self.partner_tag)
|
|
163
|
+
kwargs["browse_node_ids"] = browse_node_ids
|
|
164
|
+
|
|
165
|
+
request_obj = GetBrowseNodesRequest(**kwargs)
|
|
166
|
+
payload = request_obj.model_dump(by_alias=True, exclude_none=True)
|
|
167
|
+
|
|
168
|
+
data = await self._request("getBrowseNodes", payload)
|
|
169
|
+
return GetBrowseNodesResponse(**data)
|
|
170
|
+
|
|
171
|
+
async def get_variations(self, asin: str, **kwargs) -> GetVariationsResponse:
|
|
172
|
+
"""
|
|
173
|
+
Get all variations (e.g. size, colors) for a given parent ASIN.
|
|
174
|
+
"""
|
|
175
|
+
kwargs.setdefault("marketplace", self.marketplace)
|
|
176
|
+
kwargs.setdefault("partner_tag", self.partner_tag)
|
|
177
|
+
kwargs["asin"] = asin
|
|
178
|
+
|
|
179
|
+
request_obj = GetVariationsRequest(**kwargs)
|
|
180
|
+
payload = request_obj.model_dump(by_alias=True, exclude_none=True)
|
|
181
|
+
|
|
182
|
+
data = await self._request("getVariations", payload)
|
|
183
|
+
return GetVariationsResponse(**data)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def close(self):
|
|
187
|
+
"""Close the underlying httpx client if we own it."""
|
|
188
|
+
if self._owns_client:
|
|
189
|
+
await self._client.aclose()
|
|
190
|
+
|
|
191
|
+
async def __aenter__(self):
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
195
|
+
await self.close()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class AmazonCreatorsException(Exception):
|
|
2
|
+
"""Base exception for all Amazon Creators API errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class AuthenticationError(AmazonCreatorsException):
|
|
6
|
+
"""Raised when there is an issue obtaining or refreshing the OAuth token."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class RateLimitError(AmazonCreatorsException):
|
|
10
|
+
"""Raised when the API returns a 429 TooManyRequests."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class InvalidRequestError(AmazonCreatorsException):
|
|
14
|
+
"""Raised when the API returns a 400 Bad Request."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class APIError(AmazonCreatorsException):
|
|
18
|
+
"""Raised for general API errors (e.g. 500 Internal Server Error)."""
|
|
19
|
+
def __init__(self, message: str, status_code: int = None, type: str = None, code: str = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.type = type
|
|
23
|
+
self.code = code
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Tuple
|
|
2
|
+
from aiolimiter import AsyncLimiter
|
|
3
|
+
|
|
4
|
+
class RateLimiter:
|
|
5
|
+
"""
|
|
6
|
+
Ensures that API calls do not exceed the Amazon Creators API rate limits.
|
|
7
|
+
The default is 1 TPS (Transaction Per Second) for the initial 30 days.
|
|
8
|
+
"""
|
|
9
|
+
def __init__(self, tps: float = 1.0):
|
|
10
|
+
if tps <= 0:
|
|
11
|
+
raise ValueError("tps must be greater than 0")
|
|
12
|
+
|
|
13
|
+
# aiolimiter takes max_rate and time_period.
|
|
14
|
+
# e.g., max_rate=1, time_period=1 => 1 request per 1 second
|
|
15
|
+
self.tps = tps
|
|
16
|
+
max_rate, time_period = limiter_config_from_tps(tps)
|
|
17
|
+
self._limiter = AsyncLimiter(max_rate=max_rate, time_period=time_period)
|
|
18
|
+
|
|
19
|
+
async def acquire(self):
|
|
20
|
+
"""
|
|
21
|
+
Wait until we have capacity to make a request based on the allowed TPS.
|
|
22
|
+
"""
|
|
23
|
+
await self._limiter.acquire()
|
|
24
|
+
|
|
25
|
+
def limiter_config_from_tps(tps: float) -> Tuple[int, float]:
|
|
26
|
+
"""
|
|
27
|
+
Convert TPS to (max_rate, time_period) for aiolimiter.
|
|
28
|
+
For TPS < 1, enforce one request every 1/tps seconds.
|
|
29
|
+
For TPS >= 1, use max_rate requests per second.
|
|
30
|
+
"""
|
|
31
|
+
if tps < 1.0:
|
|
32
|
+
return 1, 1.0 / tps
|
|
33
|
+
return int(tps), 1.0
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from .requests import (
|
|
2
|
+
GetItemsRequest,
|
|
3
|
+
SearchItemsRequest,
|
|
4
|
+
GetBrowseNodesRequest,
|
|
5
|
+
GetVariationsRequest,
|
|
6
|
+
BaseAPIRequest
|
|
7
|
+
)
|
|
8
|
+
from .responses import (
|
|
9
|
+
GetItemsResponse,
|
|
10
|
+
SearchItemsResponse,
|
|
11
|
+
GetBrowseNodesResponse,
|
|
12
|
+
GetVariationsResponse,
|
|
13
|
+
Item,
|
|
14
|
+
SearchResult,
|
|
15
|
+
Image,
|
|
16
|
+
Images,
|
|
17
|
+
Price,
|
|
18
|
+
ItemInfo,
|
|
19
|
+
Listing,
|
|
20
|
+
OffersV2,
|
|
21
|
+
BrowseNode,
|
|
22
|
+
BrowseNodesResult,
|
|
23
|
+
VariationDimension,
|
|
24
|
+
VariationsResult
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"GetItemsRequest",
|
|
29
|
+
"SearchItemsRequest",
|
|
30
|
+
"GetBrowseNodesRequest",
|
|
31
|
+
"GetVariationsRequest",
|
|
32
|
+
"BaseAPIRequest",
|
|
33
|
+
"GetItemsResponse",
|
|
34
|
+
"SearchItemsResponse",
|
|
35
|
+
"GetBrowseNodesResponse",
|
|
36
|
+
"GetVariationsResponse",
|
|
37
|
+
"Item",
|
|
38
|
+
"SearchResult",
|
|
39
|
+
"Image",
|
|
40
|
+
"Images",
|
|
41
|
+
"Price",
|
|
42
|
+
"ItemInfo",
|
|
43
|
+
"Listing",
|
|
44
|
+
"OffersV2",
|
|
45
|
+
"BrowseNode",
|
|
46
|
+
"BrowseNodesResult",
|
|
47
|
+
"VariationDimension",
|
|
48
|
+
"VariationsResult"
|
|
49
|
+
]
|
|
50
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
3
|
+
|
|
4
|
+
class BaseAPIRequest(BaseModel):
|
|
5
|
+
"""
|
|
6
|
+
Base configuration for Amazon Creators API requests.
|
|
7
|
+
Forces lowerCamelCase as required by the new API.
|
|
8
|
+
"""
|
|
9
|
+
model_config = ConfigDict(
|
|
10
|
+
populate_by_name=True,
|
|
11
|
+
alias_generator=lambda string: "".join(
|
|
12
|
+
word.capitalize() if i > 0 else word for i, word in enumerate(string.split("_"))
|
|
13
|
+
)
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
class GetItemsRequest(BaseAPIRequest):
|
|
17
|
+
item_ids: List[str] = Field(min_length=1, max_length=10)
|
|
18
|
+
partner_tag: str
|
|
19
|
+
partner_type: str = "Associates"
|
|
20
|
+
marketplace: str
|
|
21
|
+
resources: Optional[List[str]] = None
|
|
22
|
+
condition: Optional[str] = None
|
|
23
|
+
currency_of_preference: Optional[str] = None
|
|
24
|
+
languages_of_preference: Optional[List[str]] = None
|
|
25
|
+
merchant: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
class SearchItemsRequest(BaseAPIRequest):
|
|
28
|
+
keywords: Optional[str] = None
|
|
29
|
+
actor: Optional[str] = None
|
|
30
|
+
artist: Optional[str] = None
|
|
31
|
+
author: Optional[str] = None
|
|
32
|
+
brand: Optional[str] = None
|
|
33
|
+
browse_node_id: Optional[str] = None
|
|
34
|
+
condition: Optional[str] = None
|
|
35
|
+
currency_of_preference: Optional[str] = None
|
|
36
|
+
delivery_flags: Optional[List[str]] = None
|
|
37
|
+
item_count: Optional[int] = Field(ge=1, le=10, default=10) # 10 items max per page usually
|
|
38
|
+
item_page: Optional[int] = Field(ge=1, le=10, default=1)
|
|
39
|
+
languages_of_preference: Optional[List[str]] = None
|
|
40
|
+
marketplace: str
|
|
41
|
+
merchant: Optional[str] = None
|
|
42
|
+
min_price: Optional[int] = None
|
|
43
|
+
max_price: Optional[int] = None
|
|
44
|
+
partner_tag: str
|
|
45
|
+
partner_type: str = "Associates"
|
|
46
|
+
resources: Optional[List[str]] = None
|
|
47
|
+
search_index: Optional[str] = "All"
|
|
48
|
+
sort_by: Optional[str] = None
|
|
49
|
+
title: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
@model_validator(mode="after")
|
|
52
|
+
def validate_search_criteria(self):
|
|
53
|
+
if not any(
|
|
54
|
+
[
|
|
55
|
+
self.keywords,
|
|
56
|
+
self.actor,
|
|
57
|
+
self.artist,
|
|
58
|
+
self.author,
|
|
59
|
+
self.brand,
|
|
60
|
+
self.browse_node_id,
|
|
61
|
+
self.title,
|
|
62
|
+
]
|
|
63
|
+
):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"At least one search criterion must be provided: "
|
|
66
|
+
"keywords, actor, artist, author, brand, browse_node_id, or title."
|
|
67
|
+
)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
class GetBrowseNodesRequest(BaseAPIRequest):
|
|
71
|
+
browse_node_ids: List[str] = Field(min_length=1, max_length=10)
|
|
72
|
+
partner_tag: str
|
|
73
|
+
partner_type: str = "Associates"
|
|
74
|
+
marketplace: str
|
|
75
|
+
languages_of_preference: Optional[List[str]] = None
|
|
76
|
+
resources: Optional[List[str]] = None
|
|
77
|
+
|
|
78
|
+
class GetVariationsRequest(BaseAPIRequest):
|
|
79
|
+
asin: str
|
|
80
|
+
partner_tag: str
|
|
81
|
+
partner_type: str = "Associates"
|
|
82
|
+
marketplace: str
|
|
83
|
+
condition: Optional[str] = None
|
|
84
|
+
currency_of_preference: Optional[str] = None
|
|
85
|
+
languages_of_preference: Optional[List[str]] = None
|
|
86
|
+
merchant: Optional[str] = None
|
|
87
|
+
offer_count: Optional[int] = Field(ge=1, le=10, default=1)
|
|
88
|
+
resources: Optional[List[str]] = None
|
|
89
|
+
variation_count: Optional[int] = Field(ge=1, le=10, default=10)
|
|
90
|
+
variation_page: Optional[int] = Field(ge=1, le=10, default=1)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from typing import List, Optional, Dict, Any
|
|
2
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
3
|
+
|
|
4
|
+
class BaseAPIResponse(BaseModel):
|
|
5
|
+
"""
|
|
6
|
+
Base configuration for Amazon Creators API responses.
|
|
7
|
+
Ensures lowerCamelCase mapping from the API JSON.
|
|
8
|
+
"""
|
|
9
|
+
model_config = ConfigDict(
|
|
10
|
+
populate_by_name=True,
|
|
11
|
+
alias_generator=lambda string: "".join(
|
|
12
|
+
word.capitalize() if i > 0 else word for i, word in enumerate(string.split("_"))
|
|
13
|
+
)
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
class Image(BaseAPIResponse):
|
|
17
|
+
url: Optional[str] = Field(None, alias="url")
|
|
18
|
+
height: Optional[int] = None
|
|
19
|
+
width: Optional[int] = None
|
|
20
|
+
|
|
21
|
+
class Images(BaseAPIResponse):
|
|
22
|
+
primary: Optional[Dict[str, Image]] = None
|
|
23
|
+
variants: Optional[List[Dict[str, Image]]] = None
|
|
24
|
+
|
|
25
|
+
class Price(BaseAPIResponse):
|
|
26
|
+
amount: Optional[float] = None
|
|
27
|
+
currency: Optional[str] = None
|
|
28
|
+
display_amount: Optional[str] = None
|
|
29
|
+
savings: Optional[Dict[str, Any]] = None
|
|
30
|
+
|
|
31
|
+
class ItemInfo(BaseAPIResponse):
|
|
32
|
+
title: Optional[Dict[str, str]] = None
|
|
33
|
+
features: Optional[Dict[str, List[str]]] = None
|
|
34
|
+
by_line_info: Optional[Dict[str, Any]] = None
|
|
35
|
+
|
|
36
|
+
class Listing(BaseAPIResponse):
|
|
37
|
+
id: Optional[str] = None
|
|
38
|
+
price: Optional[Price] = None
|
|
39
|
+
delivery_info: Optional[Dict[str, Any]] = None
|
|
40
|
+
condition: Optional[Dict[str, str]] = None
|
|
41
|
+
|
|
42
|
+
class OffersV2(BaseAPIResponse):
|
|
43
|
+
listings: Optional[List[Listing]] = None
|
|
44
|
+
summaries: Optional[List[Dict[str, Any]]] = None
|
|
45
|
+
|
|
46
|
+
class Item(BaseAPIResponse):
|
|
47
|
+
asin: str
|
|
48
|
+
detail_page_url: Optional[str] = None
|
|
49
|
+
images: Optional[Images] = None
|
|
50
|
+
item_info: Optional[ItemInfo] = None
|
|
51
|
+
offers_v2: Optional[OffersV2] = None
|
|
52
|
+
|
|
53
|
+
class SearchResult(BaseAPIResponse):
|
|
54
|
+
total_result_count: Optional[int] = None
|
|
55
|
+
search_url: Optional[str] = None
|
|
56
|
+
items: List[Item] = Field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
class SearchItemsResponse(BaseAPIResponse):
|
|
59
|
+
search_result: Optional[SearchResult] = None
|
|
60
|
+
errors: Optional[List[Dict[str, str]]] = None
|
|
61
|
+
|
|
62
|
+
class GetItemsResult(BaseAPIResponse):
|
|
63
|
+
items: List[Item] = Field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
class GetItemsResponse(BaseAPIResponse):
|
|
66
|
+
items_result: Optional[GetItemsResult] = None
|
|
67
|
+
errors: Optional[List[Dict[str, str]]] = None
|
|
68
|
+
|
|
69
|
+
# Model for Traverse Tree of BrowseNodes
|
|
70
|
+
class BrowseNode(BaseAPIResponse):
|
|
71
|
+
id: Optional[str] = None
|
|
72
|
+
display_name: Optional[str] = None
|
|
73
|
+
context_free_name: Optional[str] = None
|
|
74
|
+
is_root: Optional[bool] = None
|
|
75
|
+
ancestor: Optional['BrowseNode'] = None
|
|
76
|
+
children: Optional[List['BrowseNode']] = None
|
|
77
|
+
|
|
78
|
+
class BrowseNodesResult(BaseAPIResponse):
|
|
79
|
+
browse_nodes: List[BrowseNode] = Field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
class GetBrowseNodesResponse(BaseAPIResponse):
|
|
82
|
+
browse_nodes_result: Optional[BrowseNodesResult] = None
|
|
83
|
+
errors: Optional[List[Dict[str, str]]] = None
|
|
84
|
+
|
|
85
|
+
class VariationDimension(BaseAPIResponse):
|
|
86
|
+
display_name: str
|
|
87
|
+
name: str
|
|
88
|
+
|
|
89
|
+
class VariationsResult(BaseAPIResponse):
|
|
90
|
+
items: List[Item] = Field(default_factory=list)
|
|
91
|
+
variation_dimensions: Optional[List[VariationDimension]] = None
|
|
92
|
+
|
|
93
|
+
class GetVariationsResponse(BaseAPIResponse):
|
|
94
|
+
variations_result: Optional[VariationsResult] = None
|
|
95
|
+
errors: Optional[List[Dict[str, str]]] = None
|
|
96
|
+
|
|
97
|
+
# Deal with recursive model definition for Pydantic v2
|
|
98
|
+
BrowseNode.model_rebuild()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities and constants for the Amazon Creators API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
class Region(str, Enum):
|
|
8
|
+
NORTH_AMERICA = "NA"
|
|
9
|
+
EUROPE = "EU"
|
|
10
|
+
FAR_EAST = "FE"
|
|
11
|
+
|
|
12
|
+
def get_api_endpoint(region: Region) -> str:
|
|
13
|
+
"""Returns the endpoint for the respective region."""
|
|
14
|
+
return "https://creatorsapi.amazon/catalog/v1"
|
|
15
|
+
|
|
16
|
+
def get_version_for_region(region: Region) -> str:
|
|
17
|
+
"""Returns the authorization version for a given Region."""
|
|
18
|
+
if region == Region.NORTH_AMERICA:
|
|
19
|
+
return "2.1"
|
|
20
|
+
elif region == Region.EUROPE:
|
|
21
|
+
return "2.2"
|
|
22
|
+
elif region == Region.FAR_EAST:
|
|
23
|
+
return "2.3"
|
|
24
|
+
raise ValueError(f"Unknown region: {region}")
|
|
25
|
+
|
|
26
|
+
def get_auth_endpoint(version: str) -> str:
|
|
27
|
+
"""Returns the correct OAuth2 token endpoint based on version."""
|
|
28
|
+
if version == "2.1":
|
|
29
|
+
return "https://creatorsapi.auth.us-east-1.amazoncognito.com/oauth2/token"
|
|
30
|
+
elif version == "2.2":
|
|
31
|
+
return "https://creatorsapi.auth.eu-south-2.amazoncognito.com/oauth2/token"
|
|
32
|
+
elif version == "2.3":
|
|
33
|
+
return "https://creatorsapi.auth.us-west-2.amazoncognito.com/oauth2/token"
|
|
34
|
+
elif version == "3.1":
|
|
35
|
+
return "https://api.amazon.com/auth/o2/token"
|
|
36
|
+
elif version == "3.2":
|
|
37
|
+
return "https://api.amazon.co.uk/auth/o2/token"
|
|
38
|
+
elif version == "3.3":
|
|
39
|
+
return "https://api.amazon.co.jp/auth/o2/token"
|
|
40
|
+
raise ValueError(f"Unsupported version: {version}")
|
|
41
|
+
|
|
42
|
+
def get_scope(version: str) -> str:
|
|
43
|
+
"""Returns the correct OAuth2 scope based on version."""
|
|
44
|
+
if version.startswith("3."):
|
|
45
|
+
return "creatorsapi::default" # LWA scope
|
|
46
|
+
return "creatorsapi/default" # Cognito scope
|
|
47
|
+
|
|
48
|
+
def validate_marketplace(marketplace: str):
|
|
49
|
+
"""Simple check for typical marketplace domains."""
|
|
50
|
+
if not marketplace.startswith("www.amazon."):
|
|
51
|
+
raise ValueError("Marketplace must be a valid Amazon domain (e.g. 'www.amazon.com', 'www.amazon.com.br')")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "amazon-creators-async"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A modern, high-performance asynchronous Python wrapper for the new Amazon Creators API (OAuth 2.0 based)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Igor", email = "igor@example.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["amazon", "creators", "api", "affiliate", "async", "httpx", "oauth2"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"httpx>=0.24.0",
|
|
32
|
+
"pydantic>=2.0.0",
|
|
33
|
+
"aiolimiter>=1.1.0"
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8.0.0",
|
|
39
|
+
"pytest-asyncio>=0.23.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/ils15/amazon-creators-async"
|
|
44
|
+
Repository = "https://github.com/ils15/amazon-creators-async.git"
|
|
45
|
+
"Bug Tracker" = "https://github.com/ils15/amazon-creators-async/issues"
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
testpaths = ["tests", "."]
|
|
50
|
+
|