amazon-sp-cli 0.1.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.
- amazon_sp_cli/__init__.py +2 -0
- amazon_sp_cli/auth.py +103 -0
- amazon_sp_cli/client.py +104 -0
- amazon_sp_cli/main.py +175 -0
- amazon_sp_cli-0.1.0.dist-info/METADATA +29 -0
- amazon_sp_cli-0.1.0.dist-info/RECORD +13 -0
- amazon_sp_cli-0.1.0.dist-info/WHEEL +5 -0
- amazon_sp_cli-0.1.0.dist-info/entry_points.txt +2 -0
- amazon_sp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- amazon_sp_cli-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_auth.py +116 -0
- tests/test_client.py +59 -0
amazon_sp_cli/auth.py
ADDED
|
@@ -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.")
|
amazon_sp_cli/client.py
ADDED
|
@@ -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)
|
amazon_sp_cli/main.py
ADDED
|
@@ -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,13 @@
|
|
|
1
|
+
amazon_sp_cli/__init__.py,sha256=T_4puy5VIHfteU5oUY564XscbZMR1JoUo2w7069H3sA,42
|
|
2
|
+
amazon_sp_cli/auth.py,sha256=P0Fabbd7EqUDeiHQap9vJFvqaGxqLgPfQMO9CG4rIHY,3297
|
|
3
|
+
amazon_sp_cli/client.py,sha256=s1x7xflay5AKcSnK3OGHtyiv1xFnEbbuuDpzpvlbpBA,3765
|
|
4
|
+
amazon_sp_cli/main.py,sha256=udsXL-1fwtouxtOYqjM_HKjczxs6pZ23iJjaUCtJB4g,5773
|
|
5
|
+
amazon_sp_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Rtg-CZrSD0Uralzy3OEr5wexL20SRgFiWxXINIR9up0,1071
|
|
6
|
+
tests/__init__.py,sha256=Wk73Io62J15BtlLVIzxmASDWaaJkQLevS4BLK5LDAQg,16
|
|
7
|
+
tests/test_auth.py,sha256=EXl6Tw4R7mMBulf8IkaG5SU-vv2QTBQsKKb7Ufla5Ug,3590
|
|
8
|
+
tests/test_client.py,sha256=F-YWgaHXVVN78kGWPbXfCy28qf9BhVM0ZUVNUS-2EwA,1877
|
|
9
|
+
amazon_sp_cli-0.1.0.dist-info/METADATA,sha256=UOtJUHKJoel6jUYC3i10vEqghMCzujti8Ew3SpM9yjo,951
|
|
10
|
+
amazon_sp_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
amazon_sp_cli-0.1.0.dist-info/entry_points.txt,sha256=Kj3g2CyK22IQqjbT8TV2TR8b7LzckliU3Fy6oYEjqOU,50
|
|
12
|
+
amazon_sp_cli-0.1.0.dist-info/top_level.txt,sha256=I2NfsRf7HfqfWbhTmATISQf055_xqBKLfrQlLHrcFCU,20
|
|
13
|
+
amazon_sp_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Tests package
|
tests/test_auth.py
ADDED
|
@@ -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
|
tests/test_client.py
ADDED
|
@@ -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()
|