amazon-sp-cli 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stellar Aether
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,3 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include tests *.py
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: amazon-sp-cli
3
+ Version: 0.1.0
4
+ Summary: CLI tool for Amazon Selling Partner API (SP-API) operations
5
+ Home-page: https://github.com/stellaraether/amazon-sp-cli
6
+ Author: Lunan Li
7
+ Author-email: lunan@stellaraether.com
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Requires-Python: >=3.8
17
+ License-File: LICENSE
18
+ Requires-Dist: click>=8.0
19
+ Requires-Dist: requests>=2.27.0
20
+ Requires-Dist: boto3>=1.20.0
21
+ Requires-Dist: pyyaml>=6.0
22
+ Dynamic: author
23
+ Dynamic: author-email
24
+ Dynamic: classifier
25
+ Dynamic: home-page
26
+ Dynamic: license-file
27
+ Dynamic: requires-dist
28
+ Dynamic: requires-python
29
+ Dynamic: summary
@@ -0,0 +1,100 @@
1
+ # Amazon SP-API CLI
2
+
3
+ Command line interface for Amazon Selling Partner API (SP-API) operations.
4
+
5
+ ## Features
6
+
7
+ - **Pricing Management** — Get/set prices, create discounts
8
+ - **Listing Operations** — View and update listings
9
+ - **Catalog Lookup** — Check competitor ASINs
10
+ - **Inventory Checks** — View FBA inventory levels
11
+ - **Feed Submissions** — Bulk updates via feeds API
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install amazon-sp-cli
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ Create credentials file at `~/.config/amazon-sp-cli/credentials.yml`:
22
+
23
+ ```yaml
24
+ version: '1.0'
25
+
26
+ default:
27
+ refresh_token: "your-refresh-token"
28
+ client_id: "your-client-id"
29
+ client_secret: "your-client-secret"
30
+ aws_access_key_id: "your-aws-access-key"
31
+ aws_secret_access_key: "your-aws-secret-key"
32
+ seller_id: "your-seller-id"
33
+ marketplace_id: "ATVPDKIKX0DER" # US marketplace
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ### Pricing
39
+
40
+ ```bash
41
+ # Get current price
42
+ amz-sp pricing get PAW2603190101-BLU
43
+
44
+ # Set new price
45
+ amz-sp pricing set PAW2603190101-BLU 11.99
46
+
47
+ # Create discount feed
48
+ amz-sp pricing discount PAW2603190101-BLU 23
49
+ ```
50
+
51
+ ### Listings
52
+
53
+ ```bash
54
+ # Get listing details
55
+ amz-sp listings get PAW2603190101-BLU
56
+
57
+ # Update listing
58
+ amz-sp listings update PAW2603190101-BLU --data '{...}'
59
+ ```
60
+
61
+ ### Catalog
62
+
63
+ ```bash
64
+ # Check competitor
65
+ amz-sp catalog get B0GW72JGWK
66
+ ```
67
+
68
+ ### Inventory
69
+
70
+ ```bash
71
+ # Get FBA inventory
72
+ amz-sp inventory list
73
+
74
+ # Get specific SKU
75
+ amz-sp inventory get PAW2603190101-BLU
76
+ ```
77
+
78
+ ## Development
79
+
80
+ ```bash
81
+ # Clone repo
82
+ git clone https://github.com/stellaraether/amazon-sp-cli.git
83
+ cd amazon-sp-cli
84
+
85
+ # Install in editable mode
86
+ pip install -e .
87
+
88
+ # Run locally
89
+ python3 -m amazon_sp_cli.main pricing get PAW2603190101-BLU
90
+ ```
91
+
92
+ ## Requirements
93
+
94
+ - Python 3.8+
95
+ - Amazon SP-API credentials
96
+ - AWS IAM user with SP-API access
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,2 @@
1
+ # Amazon SP-API CLI
2
+ __version__ = "0.1.0"
@@ -0,0 +1,103 @@
1
+ """Amazon SP-API authentication handler."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import requests
9
+ import yaml
10
+
11
+
12
+ class SPAPIAuth:
13
+ """Handles SP-API token refresh and caching."""
14
+
15
+ TOKEN_ENDPOINT = "https://api.amazon.com/auth/o2/token"
16
+ CACHE_FILE = Path.home() / ".config" / "amazon-sp-pricing" / "token-cache.json"
17
+ BUFFER_SECONDS = 60
18
+
19
+ def __init__(self, credentials_path: str = None):
20
+ self.credentials = self._load_credentials(credentials_path)
21
+ self._ensure_cache_dir()
22
+
23
+ def _load_credentials(self, path: str = None) -> dict:
24
+ """Load credentials from YAML file."""
25
+ if path is None:
26
+ path = Path.home() / ".config" / "amazon-sp-pricing" / "credentials.yml"
27
+
28
+ with open(path, "r") as f:
29
+ config = yaml.safe_load(f)
30
+
31
+ return config.get("default", config)
32
+
33
+ def _ensure_cache_dir(self):
34
+ """Ensure cache directory exists."""
35
+ self.CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
36
+
37
+ def _load_cache(self) -> dict:
38
+ """Load token cache from disk."""
39
+ if self.CACHE_FILE.exists():
40
+ with open(self.CACHE_FILE, "r") as f:
41
+ return json.load(f)
42
+ return {
43
+ "access_token": None,
44
+ "expires_at": 0,
45
+ "refreshed_at": None,
46
+ }
47
+
48
+ def _save_cache(self, cache: dict):
49
+ """Save token cache to disk."""
50
+ with open(self.CACHE_FILE, "w") as f:
51
+ json.dump(cache, f)
52
+ os.chmod(self.CACHE_FILE, 0o600)
53
+
54
+ def _is_token_valid(self, cache: dict) -> bool:
55
+ """Check if cached token is still valid."""
56
+ if cache.get("access_token") is None:
57
+ return False
58
+ now = time.time()
59
+ return now < cache.get("expires_at", 0) - self.BUFFER_SECONDS
60
+
61
+ def _exchange_token(self) -> dict:
62
+ """Exchange refresh token for access token."""
63
+ response = requests.post(
64
+ self.TOKEN_ENDPOINT,
65
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
66
+ data={
67
+ "grant_type": "refresh_token",
68
+ "refresh_token": self.credentials["refresh_token"],
69
+ "client_id": self.credentials["client_id"],
70
+ "client_secret": self.credentials["client_secret"],
71
+ },
72
+ )
73
+ response.raise_for_status()
74
+ data = response.json()
75
+
76
+ now = time.time()
77
+ return {
78
+ "access_token": data["access_token"],
79
+ "expires_at": now + data["expires_in"],
80
+ "refreshed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
81
+ }
82
+
83
+ def get_access_token(self) -> str:
84
+ """Get a valid access token, refreshing if necessary."""
85
+ cache = self._load_cache()
86
+
87
+ # Fast path: valid cached token
88
+ if self._is_token_valid(cache):
89
+ return cache["access_token"]
90
+
91
+ # Slow path: refresh token
92
+ new_cache = self._exchange_token()
93
+ self._save_cache(new_cache)
94
+ return new_cache["access_token"]
95
+
96
+ def invalidate(self):
97
+ """Invalidate cached token."""
98
+ self._save_cache({
99
+ "access_token": None,
100
+ "expires_at": 0,
101
+ "refreshed_at": None,
102
+ })
103
+ print("Token cache invalidated.")
@@ -0,0 +1,104 @@
1
+ """Amazon SP-API client with AWS SigV4 signing."""
2
+
3
+ import json
4
+ from urllib.parse import urlencode
5
+
6
+ import requests
7
+ from botocore.auth import SigV4Auth
8
+ from botocore.awsrequest import AWSRequest
9
+ from botocore.credentials import Credentials
10
+ from botocore.session import Session
11
+
12
+
13
+ class SPAPIClient:
14
+ """Client for making signed requests to Amazon SP-API."""
15
+
16
+ HOST = "sellingpartnerapi-na.amazon.com"
17
+ REGION = "us-east-1"
18
+ SERVICE = "execute-api"
19
+
20
+ def __init__(self, auth, aws_access_key: str = None, aws_secret_key: str = None):
21
+ self.auth = auth
22
+ self.credentials = auth.credentials
23
+ self.aws_access_key = aws_access_key or self.credentials.get("aws_access_key_id")
24
+ self.aws_secret_key = aws_secret_key or self.credentials.get("aws_secret_access_key")
25
+ self.marketplace_id = self.credentials.get("marketplace_id", "ATVPDKIKX0DER")
26
+ self.seller_id = self.credentials.get("seller_id")
27
+
28
+ def _sign_request(self, method: str, path: str, data: dict = None) -> dict:
29
+ """Create signed request headers."""
30
+ access_token = self.auth.get_access_token()
31
+
32
+ headers = {
33
+ "x-amz-access-token": access_token,
34
+ "accept": "application/json",
35
+ }
36
+
37
+ if data is not None:
38
+ headers["content-type"] = "application/json"
39
+
40
+ # Create the request
41
+ url = f"https://{self.HOST}{path}"
42
+ body = json.dumps(data) if data else ""
43
+
44
+ # Create AWS request for signing
45
+ request = AWSRequest(method=method, url=url, headers=headers, data=body)
46
+
47
+ # Sign with SigV4
48
+ credentials = Credentials(self.aws_access_key, self.aws_secret_key)
49
+ signer = SigV4Auth(credentials, self.SERVICE, self.REGION)
50
+ signer.add_auth(request)
51
+
52
+ return dict(request.headers), request.url, body
53
+
54
+ def request(self, method: str, path: str, data: dict = None) -> dict:
55
+ """Make a signed request to SP-API."""
56
+ headers, url, body = self._sign_request(method, path, data)
57
+
58
+ response = requests.request(method, url, headers=headers, data=body)
59
+ response.raise_for_status()
60
+
61
+ return response.json() if response.text else {}
62
+
63
+ def get_listing(self, sku: str) -> dict:
64
+ """Get listing information for a SKU."""
65
+ path = f"/listings/2021-08-01/items/{self.seller_id}/{sku}"
66
+ params = {
67
+ "marketplaceIds": self.marketplace_id,
68
+ "includedData": "summaries,attributes",
69
+ }
70
+ path += "?" + urlencode(params)
71
+ return self.request("GET", path)
72
+
73
+ def update_price(self, sku: str, price: float, mode: str = "VALIDATION_PREVIEW") -> dict:
74
+ """Update listing price."""
75
+ path = f"/listings/2021-08-01/items/{self.seller_id}/{sku}"
76
+ params = {
77
+ "marketplaceIds": self.marketplace_id,
78
+ }
79
+ if mode:
80
+ params["mode"] = mode
81
+ path += "?" + urlencode(params)
82
+
83
+ data = {
84
+ "productType": "PET_TOY",
85
+ "requirements": "LISTING_OFFER_ONLY",
86
+ "attributes": {
87
+ "condition_type": [{"value": "new_new"}],
88
+ "item_name": [{"value": "Placeholder"}],
89
+ "list_price": [{"currency": "USD", "value": price}],
90
+ "purchasable_price": [{"currency": "USD", "value": price}],
91
+ },
92
+ }
93
+
94
+ return self.request("PUT", path, data)
95
+
96
+ def get_catalog_item(self, asin: str) -> dict:
97
+ """Get catalog item details."""
98
+ path = f"/catalog/2022-04-01/items/{asin}"
99
+ params = {
100
+ "marketplaceIds": self.marketplace_id,
101
+ "includedData": "attributes,salesRanks",
102
+ }
103
+ path += "?" + urlencode(params)
104
+ return self.request("GET", path)
@@ -0,0 +1,175 @@
1
+ """Main CLI entry point for Amazon SP-API CLI."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+
6
+ import click
7
+
8
+ from .auth import SPAPIAuth
9
+ from .client import SPAPIClient
10
+
11
+
12
+ @click.group()
13
+ @click.option("--credentials", "-c", help="Path to credentials YAML file")
14
+ @click.pass_context
15
+ def cli(ctx, credentials):
16
+ """Amazon SP-API CLI - Manage listings, pricing, inventory, and more."""
17
+ ctx.ensure_object(dict)
18
+ ctx.obj["auth"] = SPAPIAuth(credentials)
19
+ ctx.obj["client"] = SPAPIClient(ctx.obj["auth"])
20
+
21
+
22
+ @cli.command()
23
+ @click.argument("sku")
24
+ @click.pass_context
25
+ def get_price(ctx, sku):
26
+ """Get current price for a SKU."""
27
+ client = ctx.obj["client"]
28
+ try:
29
+ response = client.get_listing(sku)
30
+ attributes = response.get("attributes", {})
31
+ list_price = attributes.get("list_price", [{}])[0]
32
+
33
+ result = {
34
+ "sku": sku,
35
+ "asin": response.get("summaries", [{}])[0].get("asin"),
36
+ "status": response.get("summaries", [{}])[0].get("status", []),
37
+ "price": list_price.get("value"),
38
+ "currency": list_price.get("currency"),
39
+ }
40
+ click.echo(json.dumps(result, indent=2))
41
+ except Exception as e:
42
+ click.echo(f"Error: {e}", err=True)
43
+ raise click.Abort()
44
+
45
+
46
+ @cli.command()
47
+ @click.argument("sku")
48
+ @click.argument("price", type=float)
49
+ @click.option("--dry-run", is_flag=True, help="Validate without applying")
50
+ @click.pass_context
51
+ def set_price(ctx, sku, price, dry_run):
52
+ """Set price for a SKU."""
53
+ client = ctx.obj["client"]
54
+ try:
55
+ mode = "VALIDATION_PREVIEW" if dry_run else None
56
+ response = client.update_price(sku, price, mode)
57
+
58
+ if dry_run:
59
+ if response.get("issues"):
60
+ click.echo("Validation issues found:")
61
+ click.echo(json.dumps(response["issues"], indent=2))
62
+ else:
63
+ click.echo("✓ Validation passed")
64
+ else:
65
+ click.echo(json.dumps(response, indent=2))
66
+ except Exception as e:
67
+ click.echo(f"Error: {e}", err=True)
68
+ raise click.Abort()
69
+
70
+
71
+ @cli.command()
72
+ @click.argument("sku")
73
+ @click.argument("percent", type=float)
74
+ @click.option("--all-variations", is_flag=True, help="Apply to all variations")
75
+ @click.pass_context
76
+ def create_discount(ctx, sku, percent, all_variations):
77
+ """Create discount for a SKU."""
78
+ client = ctx.obj["client"]
79
+
80
+ try:
81
+ if all_variations:
82
+ # Get parent SKU and find all variations
83
+ parent_sku = sku.split("-")[0] if "-" in sku else sku
84
+ click.echo(f"Creating {percent}% discount for all variations of {parent_sku}")
85
+ # TODO: Implement variation discovery
86
+ return
87
+
88
+ # Get current price
89
+ response = client.get_listing(sku)
90
+ attributes = response.get("attributes", {})
91
+ list_price = attributes.get("list_price", [{}])[0]
92
+ current_price = list_price.get("value", 0)
93
+
94
+ if not current_price:
95
+ click.echo("Error: Could not get current price", err=True)
96
+ raise click.Abort()
97
+
98
+ sale_price = round(current_price * (100 - percent) / 100, 2)
99
+ effective_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
100
+
101
+ click.echo(f"Current price: ${current_price}")
102
+ click.echo(f"Sale price: ${sale_price} ({percent}% off)")
103
+ click.echo("")
104
+ click.echo("Note: SP-API doesn't support direct discount creation.")
105
+ click.echo("Options:")
106
+ click.echo("1. Seller Central → Advertising → Prime Exclusive Discounts")
107
+ click.echo("2. Seller Central → Advertising → Coupons")
108
+ click.echo("3. Submit feed via SP-API Feeds API")
109
+ click.echo("")
110
+ click.echo("Feed data for option 3:")
111
+
112
+ feed = {
113
+ "header": {
114
+ "sellerId": client.seller_id,
115
+ "version": "2.0",
116
+ "issueLocale": "en_US",
117
+ },
118
+ "messages": [
119
+ {
120
+ "messageId": 1,
121
+ "sku": sku,
122
+ "operationType": "PARTIAL_UPDATE",
123
+ "productType": "PET_TOY",
124
+ "attributes": {
125
+ "list_price": [{"currency": "USD", "value": current_price}],
126
+ "sale_price": [
127
+ {
128
+ "currency": "USD",
129
+ "value": sale_price,
130
+ "effective_date": effective_date,
131
+ }
132
+ ],
133
+ },
134
+ }
135
+ ],
136
+ }
137
+ click.echo(json.dumps(feed, indent=2))
138
+
139
+ except Exception as e:
140
+ click.echo(f"Error: {e}", err=True)
141
+ raise click.Abort()
142
+
143
+
144
+ @cli.command()
145
+ @click.argument("asin")
146
+ @click.pass_context
147
+ def check_competitors(ctx, asin):
148
+ """Check competitor pricing for an ASIN."""
149
+ client = ctx.obj["client"]
150
+ try:
151
+ response = client.get_catalog_item(asin)
152
+ attributes = response.get("attributes", {})
153
+
154
+ result = {
155
+ "asin": response.get("asin"),
156
+ "title": attributes.get("item_name", [{}])[0].get("value"),
157
+ "brand": attributes.get("brand", [{}])[0].get("value"),
158
+ "list_price": attributes.get("list_price", [{}])[0].get("value"),
159
+ "sales_rank": response.get("salesRanks", [{}])[0].get("displayRank"),
160
+ }
161
+ click.echo(json.dumps(result, indent=2))
162
+ except Exception as e:
163
+ click.echo(f"Error: {e}", err=True)
164
+ raise click.Abort()
165
+
166
+
167
+ @cli.command()
168
+ @click.pass_context
169
+ def invalidate(ctx):
170
+ """Invalidate cached access token."""
171
+ ctx.obj["auth"].invalidate()
172
+
173
+
174
+ if __name__ == "__main__":
175
+ cli()
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: amazon-sp-cli
3
+ Version: 0.1.0
4
+ Summary: CLI tool for Amazon Selling Partner API (SP-API) operations
5
+ Home-page: https://github.com/stellaraether/amazon-sp-cli
6
+ Author: Lunan Li
7
+ Author-email: lunan@stellaraether.com
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Requires-Python: >=3.8
17
+ License-File: LICENSE
18
+ Requires-Dist: click>=8.0
19
+ Requires-Dist: requests>=2.27.0
20
+ Requires-Dist: boto3>=1.20.0
21
+ Requires-Dist: pyyaml>=6.0
22
+ Dynamic: author
23
+ Dynamic: author-email
24
+ Dynamic: classifier
25
+ Dynamic: home-page
26
+ Dynamic: license-file
27
+ Dynamic: requires-dist
28
+ Dynamic: requires-python
29
+ Dynamic: summary
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ setup.py
6
+ amazon_sp_cli/__init__.py
7
+ amazon_sp_cli/auth.py
8
+ amazon_sp_cli/client.py
9
+ amazon_sp_cli/main.py
10
+ amazon_sp_cli.egg-info/PKG-INFO
11
+ amazon_sp_cli.egg-info/SOURCES.txt
12
+ amazon_sp_cli.egg-info/dependency_links.txt
13
+ amazon_sp_cli.egg-info/entry_points.txt
14
+ amazon_sp_cli.egg-info/requires.txt
15
+ amazon_sp_cli.egg-info/top_level.txt
16
+ tests/__init__.py
17
+ tests/test_auth.py
18
+ tests/test_client.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ amz-sp = amazon_sp_cli.main:cli
@@ -0,0 +1,4 @@
1
+ click>=8.0
2
+ requests>=2.27.0
3
+ boto3>=1.20.0
4
+ pyyaml>=6.0
@@ -0,0 +1,2 @@
1
+ amazon_sp_cli
2
+ tests
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.black]
6
+ line-length = 120
7
+ target-version = ['py38', 'py39', 'py310', 'py311']
8
+
9
+ [tool.isort]
10
+ profile = "black"
11
+ line_length = 120
12
+
13
+ [tool.pytest.ini_options]
14
+ testpaths = ["tests"]
15
+ python_files = ["test_*.py"]
16
+ python_classes = ["Test*"]
17
+ python_functions = ["test_*"]
18
+ addopts = "--strict-markers"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ from setuptools import find_packages, setup
2
+
3
+ setup(
4
+ name="amazon-sp-cli",
5
+ version="0.1.0",
6
+ description="CLI tool for Amazon Selling Partner API (SP-API) operations",
7
+ author="Lunan Li",
8
+ author_email="lunan@stellaraether.com",
9
+ url="https://github.com/stellaraether/amazon-sp-cli",
10
+ packages=find_packages(),
11
+ install_requires=[
12
+ "click>=8.0",
13
+ "requests>=2.27.0",
14
+ "boto3>=1.20.0",
15
+ "pyyaml>=6.0",
16
+ ],
17
+ entry_points={
18
+ "console_scripts": [
19
+ "amz-sp=amazon_sp_cli.main:cli",
20
+ ],
21
+ },
22
+ python_requires=">=3.8",
23
+ classifiers=[
24
+ "Development Status :: 3 - Alpha",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.8",
29
+ "Programming Language :: Python :: 3.9",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ ],
33
+ )
@@ -0,0 +1 @@
1
+ # Tests package
@@ -0,0 +1,116 @@
1
+ """Tests for SP-API authentication."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from unittest.mock import Mock, patch
8
+
9
+ import pytest
10
+
11
+ from amazon_sp_cli.auth import SPAPIAuth
12
+
13
+
14
+ class TestSPAPIAuth:
15
+ """Test SPAPIAuth class."""
16
+
17
+ @pytest.fixture
18
+ def temp_credentials(self):
19
+ """Create temporary credentials file."""
20
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
21
+ f.write("""
22
+ default:
23
+ refresh_token: "test-refresh-token"
24
+ client_id: "test-client-id"
25
+ client_secret: "test-client-secret"
26
+ """)
27
+ path = f.name
28
+ yield path
29
+ os.unlink(path)
30
+
31
+ @pytest.fixture
32
+ def temp_cache_dir(self):
33
+ """Create temporary cache directory."""
34
+ with tempfile.TemporaryDirectory() as tmpdir:
35
+ cache_file = Path(tmpdir) / "token-cache.json"
36
+ with patch.object(SPAPIAuth, "CACHE_FILE", cache_file):
37
+ yield cache_file
38
+
39
+ def test_load_credentials(self, temp_credentials):
40
+ """Test credentials loading."""
41
+ auth = SPAPIAuth(temp_credentials)
42
+ assert auth.credentials["refresh_token"] == "test-refresh-token"
43
+ assert auth.credentials["client_id"] == "test-client-id"
44
+ assert auth.credentials["client_secret"] == "test-client-secret"
45
+
46
+ def test_token_valid(self, temp_credentials, temp_cache_dir):
47
+ """Test token validation."""
48
+ auth = SPAPIAuth(temp_credentials)
49
+
50
+ # Valid token
51
+ valid_cache = {
52
+ "access_token": "valid-token",
53
+ "expires_at": 9999999999,
54
+ }
55
+ assert auth._is_token_valid(valid_cache) is True
56
+
57
+ # Expired token
58
+ expired_cache = {
59
+ "access_token": "expired-token",
60
+ "expires_at": 0,
61
+ }
62
+ assert auth._is_token_valid(expired_cache) is False
63
+
64
+ # No token
65
+ empty_cache = {"access_token": None}
66
+ assert auth._is_token_valid(empty_cache) is False
67
+
68
+ @patch("amazon_sp_cli.auth.requests.post")
69
+ def test_exchange_token(self, mock_post, temp_credentials, temp_cache_dir):
70
+ """Test token exchange."""
71
+ mock_response = Mock()
72
+ mock_response.json.return_value = {
73
+ "access_token": "new-access-token",
74
+ "expires_in": 3600,
75
+ }
76
+ mock_response.raise_for_status = Mock()
77
+ mock_post.return_value = mock_response
78
+
79
+ auth = SPAPIAuth(temp_credentials)
80
+ cache = auth._exchange_token()
81
+
82
+ assert cache["access_token"] == "new-access-token"
83
+ assert cache["expires_at"] > 0
84
+
85
+ @patch("amazon_sp_cli.auth.requests.post")
86
+ def test_get_access_token_cached(self, mock_post, temp_credentials, temp_cache_dir):
87
+ """Test getting cached access token."""
88
+ auth = SPAPIAuth(temp_credentials)
89
+
90
+ # Pre-populate cache with valid token
91
+ cache = {
92
+ "access_token": "cached-token",
93
+ "expires_at": 9999999999,
94
+ }
95
+ auth._save_cache(cache)
96
+
97
+ token = auth.get_access_token()
98
+ assert token == "cached-token"
99
+ mock_post.assert_not_called()
100
+
101
+ def test_invalidate(self, temp_credentials, temp_cache_dir):
102
+ """Test token invalidation."""
103
+ auth = SPAPIAuth(temp_credentials)
104
+
105
+ # Pre-populate cache
106
+ cache = {
107
+ "access_token": "token",
108
+ "expires_at": 9999999999,
109
+ }
110
+ auth._save_cache(cache)
111
+
112
+ auth.invalidate()
113
+
114
+ new_cache = auth._load_cache()
115
+ assert new_cache["access_token"] is None
116
+ assert new_cache["expires_at"] == 0
@@ -0,0 +1,59 @@
1
+ """Tests for SP-API client."""
2
+
3
+ from unittest.mock import Mock, patch
4
+
5
+ import pytest
6
+
7
+ from amazon_sp_cli.auth import SPAPIAuth
8
+ from amazon_sp_cli.client import SPAPIClient
9
+
10
+
11
+ class TestSPAPIClient:
12
+ """Test SPAPIClient class."""
13
+
14
+ @pytest.fixture
15
+ def mock_auth(self):
16
+ """Create mock auth object."""
17
+ auth = Mock(spec=SPAPIAuth)
18
+ auth.credentials = {
19
+ "aws_access_key_id": "test-aws-key",
20
+ "aws_secret_access_key": "test-aws-secret",
21
+ "seller_id": "TESTSELLER",
22
+ "marketplace_id": "ATVPDKIKX0DER",
23
+ }
24
+ auth.get_access_token.return_value = "test-access-token"
25
+ return auth
26
+
27
+ @pytest.fixture
28
+ def client(self, mock_auth):
29
+ """Create SPAPIClient instance."""
30
+ return SPAPIClient(mock_auth)
31
+
32
+ def test_init(self, client, mock_auth):
33
+ """Test client initialization."""
34
+ assert client.auth == mock_auth
35
+ assert client.seller_id == "TESTSELLER"
36
+ assert client.marketplace_id == "ATVPDKIKX0DER"
37
+
38
+ def test_get_listing_path(self, client):
39
+ """Test listing path construction."""
40
+ path = f"/listings/2021-08-01/items/{client.seller_id}/TEST-SKU"
41
+ assert "TESTSELLER" in path
42
+ assert "TEST-SKU" in path
43
+
44
+ @patch("amazon_sp_cli.client.requests.request")
45
+ @patch("amazon_sp_cli.client.SigV4Auth")
46
+ def test_request(self, mock_signer_class, mock_request, client):
47
+ """Test API request."""
48
+ mock_response = Mock()
49
+ mock_response.json.return_value = {"test": "data"}
50
+ mock_response.text = '{"test": "data"}'
51
+ mock_request.return_value = mock_response
52
+
53
+ mock_signer = Mock()
54
+ mock_signer_class.return_value = mock_signer
55
+
56
+ result = client.request("GET", "/test/path")
57
+
58
+ assert result == {"test": "data"}
59
+ mock_request.assert_called_once()