amazon-sp-cli 0.1.1__tar.gz → 0.1.2__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_sp_cli-0.1.1/amazon_sp_cli.egg-info → amazon_sp_cli-0.1.2}/PKG-INFO +1 -1
- amazon_sp_cli-0.1.2/amazon_sp_cli/__main__.py +6 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli/auth.py +9 -7
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli/client.py +0 -1
- amazon_sp_cli-0.1.2/amazon_sp_cli/main.py +379 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2/amazon_sp_cli.egg-info}/PKG-INFO +1 -1
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/SOURCES.txt +3 -1
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/setup.py +4 -1
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/tests/test_auth.py +4 -3
- amazon_sp_cli-0.1.2/tests/test_coupon.py +137 -0
- amazon_sp_cli-0.1.1/amazon_sp_cli/main.py +0 -175
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/LICENSE +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/MANIFEST.in +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/README.md +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli/__init__.py +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/dependency_links.txt +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/entry_points.txt +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/requires.txt +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/top_level.txt +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/pyproject.toml +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/setup.cfg +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/tests/__init__.py +0 -0
- {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/tests/test_client.py +0 -0
|
@@ -13,7 +13,7 @@ class SPAPIAuth:
|
|
|
13
13
|
"""Handles SP-API token refresh and caching."""
|
|
14
14
|
|
|
15
15
|
TOKEN_ENDPOINT = "https://api.amazon.com/auth/o2/token"
|
|
16
|
-
CACHE_FILE = Path.home() / ".config" / "amazon-sp-
|
|
16
|
+
CACHE_FILE = Path.home() / ".config" / "amazon-sp-cli" / "token-cache.json"
|
|
17
17
|
BUFFER_SECONDS = 60
|
|
18
18
|
|
|
19
19
|
def __init__(self, credentials_path: str = None):
|
|
@@ -23,7 +23,7 @@ class SPAPIAuth:
|
|
|
23
23
|
def _load_credentials(self, path: str = None) -> dict:
|
|
24
24
|
"""Load credentials from YAML file."""
|
|
25
25
|
if path is None:
|
|
26
|
-
path = Path.home() / ".config" / "amazon-sp-
|
|
26
|
+
path = Path.home() / ".config" / "amazon-sp-cli" / "credentials.yml"
|
|
27
27
|
|
|
28
28
|
with open(path, "r") as f:
|
|
29
29
|
config = yaml.safe_load(f)
|
|
@@ -95,9 +95,11 @@ class SPAPIAuth:
|
|
|
95
95
|
|
|
96
96
|
def invalidate(self):
|
|
97
97
|
"""Invalidate cached token."""
|
|
98
|
-
self._save_cache(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
self._save_cache(
|
|
99
|
+
{
|
|
100
|
+
"access_token": None,
|
|
101
|
+
"expires_at": 0,
|
|
102
|
+
"refreshed_at": None,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
103
105
|
print("Token cache invalidated.")
|
|
@@ -0,0 +1,379 @@
|
|
|
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
|
+
def _check_path():
|
|
13
|
+
"""Check if the CLI is accessible in PATH and warn once per day."""
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
if shutil.which("amz-sp"):
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
# Only warn once per day
|
|
22
|
+
flag_file = Path.home() / ".config" / "amazon-sp-cli" / ".path-warned"
|
|
23
|
+
flag_file.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
now = datetime.now(timezone.utc)
|
|
26
|
+
today = now.strftime("%Y-%m-%d")
|
|
27
|
+
|
|
28
|
+
if flag_file.exists():
|
|
29
|
+
last_warned = flag_file.read_text().strip()
|
|
30
|
+
if last_warned == today:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
flag_file.write_text(today)
|
|
34
|
+
|
|
35
|
+
print(
|
|
36
|
+
"\n⚠️ Note: 'amz-sp' is not in your PATH.",
|
|
37
|
+
file=sys.stderr,
|
|
38
|
+
)
|
|
39
|
+
print(
|
|
40
|
+
" You can still use: python3 -m amazon_sp_cli",
|
|
41
|
+
file=sys.stderr,
|
|
42
|
+
)
|
|
43
|
+
print(
|
|
44
|
+
" To add to PATH, add this to your shell config:",
|
|
45
|
+
file=sys.stderr,
|
|
46
|
+
)
|
|
47
|
+
print(
|
|
48
|
+
f' export PATH="{sys.prefix}/bin:$PATH"',
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
print("", file=sys.stderr)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@click.group()
|
|
55
|
+
@click.option("--credentials", "-c", help="Path to credentials YAML file")
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def cli(ctx, credentials):
|
|
58
|
+
"""Amazon SP-API CLI - Manage listings, pricing, inventory, and more."""
|
|
59
|
+
_check_path()
|
|
60
|
+
ctx.ensure_object(dict)
|
|
61
|
+
ctx.obj["auth"] = SPAPIAuth(credentials)
|
|
62
|
+
ctx.obj["client"] = SPAPIClient(ctx.obj["auth"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@cli.command()
|
|
66
|
+
@click.argument("sku")
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def get_price(ctx, sku):
|
|
69
|
+
"""Get current price for a SKU."""
|
|
70
|
+
client = ctx.obj["client"]
|
|
71
|
+
try:
|
|
72
|
+
response = client.get_listing(sku)
|
|
73
|
+
attributes = response.get("attributes", {})
|
|
74
|
+
list_price = attributes.get("list_price", [{}])[0]
|
|
75
|
+
|
|
76
|
+
result = {
|
|
77
|
+
"sku": sku,
|
|
78
|
+
"asin": response.get("summaries", [{}])[0].get("asin"),
|
|
79
|
+
"status": response.get("summaries", [{}])[0].get("status", []),
|
|
80
|
+
"price": list_price.get("value"),
|
|
81
|
+
"currency": list_price.get("currency"),
|
|
82
|
+
}
|
|
83
|
+
click.echo(json.dumps(result, indent=2))
|
|
84
|
+
except Exception as e:
|
|
85
|
+
click.echo(f"Error: {e}", err=True)
|
|
86
|
+
raise click.Abort()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@cli.command()
|
|
90
|
+
@click.argument("sku")
|
|
91
|
+
@click.argument("price", type=float)
|
|
92
|
+
@click.option("--dry-run", is_flag=True, help="Validate without applying")
|
|
93
|
+
@click.pass_context
|
|
94
|
+
def set_price(ctx, sku, price, dry_run):
|
|
95
|
+
"""Set price for a SKU."""
|
|
96
|
+
client = ctx.obj["client"]
|
|
97
|
+
try:
|
|
98
|
+
mode = "VALIDATION_PREVIEW" if dry_run else None
|
|
99
|
+
response = client.update_price(sku, price, mode)
|
|
100
|
+
|
|
101
|
+
if dry_run:
|
|
102
|
+
if response.get("issues"):
|
|
103
|
+
click.echo("Validation issues found:")
|
|
104
|
+
click.echo(json.dumps(response["issues"], indent=2))
|
|
105
|
+
else:
|
|
106
|
+
click.echo("✓ Validation passed")
|
|
107
|
+
else:
|
|
108
|
+
click.echo(json.dumps(response, indent=2))
|
|
109
|
+
except Exception as e:
|
|
110
|
+
click.echo(f"Error: {e}", err=True)
|
|
111
|
+
raise click.Abort()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@cli.command()
|
|
115
|
+
@click.argument("sku")
|
|
116
|
+
@click.argument("percent", type=float)
|
|
117
|
+
@click.option("--all-variations", is_flag=True, help="Apply to all variations")
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def create_discount(ctx, sku, percent, all_variations):
|
|
120
|
+
"""Create discount for a SKU."""
|
|
121
|
+
client = ctx.obj["client"]
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
if all_variations:
|
|
125
|
+
# Get parent SKU and find all variations
|
|
126
|
+
parent_sku = sku.split("-")[0] if "-" in sku else sku
|
|
127
|
+
click.echo(f"Creating {percent}% discount for all variations of {parent_sku}")
|
|
128
|
+
# TODO: Implement variation discovery
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Get current price
|
|
132
|
+
response = client.get_listing(sku)
|
|
133
|
+
attributes = response.get("attributes", {})
|
|
134
|
+
list_price = attributes.get("list_price", [{}])[0]
|
|
135
|
+
current_price = list_price.get("value", 0)
|
|
136
|
+
|
|
137
|
+
if not current_price:
|
|
138
|
+
click.echo("Error: Could not get current price", err=True)
|
|
139
|
+
raise click.Abort()
|
|
140
|
+
|
|
141
|
+
sale_price = round(current_price * (100 - percent) / 100, 2)
|
|
142
|
+
effective_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
143
|
+
|
|
144
|
+
click.echo(f"Current price: ${current_price}")
|
|
145
|
+
click.echo(f"Sale price: ${sale_price} ({percent}% off)")
|
|
146
|
+
click.echo("")
|
|
147
|
+
click.echo("Note: SP-API doesn't support direct discount creation.")
|
|
148
|
+
click.echo("Options:")
|
|
149
|
+
click.echo("1. Seller Central → Advertising → Prime Exclusive Discounts")
|
|
150
|
+
click.echo("2. Seller Central → Advertising → Coupons")
|
|
151
|
+
click.echo("3. Submit feed via SP-API Feeds API")
|
|
152
|
+
click.echo("")
|
|
153
|
+
click.echo("Feed data for option 3:")
|
|
154
|
+
|
|
155
|
+
feed = {
|
|
156
|
+
"header": {
|
|
157
|
+
"sellerId": client.seller_id,
|
|
158
|
+
"version": "2.0",
|
|
159
|
+
"issueLocale": "en_US",
|
|
160
|
+
},
|
|
161
|
+
"messages": [
|
|
162
|
+
{
|
|
163
|
+
"messageId": 1,
|
|
164
|
+
"sku": sku,
|
|
165
|
+
"operationType": "PARTIAL_UPDATE",
|
|
166
|
+
"productType": "PET_TOY",
|
|
167
|
+
"attributes": {
|
|
168
|
+
"list_price": [{"currency": "USD", "value": current_price}],
|
|
169
|
+
"sale_price": [
|
|
170
|
+
{
|
|
171
|
+
"currency": "USD",
|
|
172
|
+
"value": sale_price,
|
|
173
|
+
"effective_date": effective_date,
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
click.echo(json.dumps(feed, indent=2))
|
|
181
|
+
|
|
182
|
+
except Exception as e:
|
|
183
|
+
click.echo(f"Error: {e}", err=True)
|
|
184
|
+
raise click.Abort()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@cli.command()
|
|
188
|
+
@click.argument("sku")
|
|
189
|
+
@click.argument("discount", type=float)
|
|
190
|
+
@click.option(
|
|
191
|
+
"--type",
|
|
192
|
+
"coupon_type",
|
|
193
|
+
type=click.Choice(["percentage", "fixed"]),
|
|
194
|
+
default="percentage",
|
|
195
|
+
help="Coupon type: percentage or fixed amount off",
|
|
196
|
+
)
|
|
197
|
+
@click.option("--min-purchase", type=float, default=0, help="Minimum purchase amount required")
|
|
198
|
+
@click.option("--start-date", help="Start date (YYYY-MM-DD). Defaults to today")
|
|
199
|
+
@click.option("--end-date", help="End date (YYYY-MM-DD). Defaults to 30 days from start")
|
|
200
|
+
@click.option("--budget", type=float, default=500, help="Total coupon budget in USD (default: 500)")
|
|
201
|
+
@click.option("--customer-budget", type=float, help="Maximum discount per customer (defaults to full discount)")
|
|
202
|
+
@click.option("--prime-only", is_flag=True, help="Prime members only")
|
|
203
|
+
@click.option("--clip-coupon", is_flag=True, default=True, help="Require customers to clip coupon (default: True)")
|
|
204
|
+
@click.option("--output", "-o", type=click.File("w"), help="Save coupon data to file")
|
|
205
|
+
@click.pass_context
|
|
206
|
+
def create_coupon(
|
|
207
|
+
ctx,
|
|
208
|
+
sku,
|
|
209
|
+
discount,
|
|
210
|
+
coupon_type,
|
|
211
|
+
min_purchase,
|
|
212
|
+
start_date,
|
|
213
|
+
end_date,
|
|
214
|
+
budget,
|
|
215
|
+
customer_budget,
|
|
216
|
+
prime_only,
|
|
217
|
+
clip_coupon,
|
|
218
|
+
output,
|
|
219
|
+
):
|
|
220
|
+
"""Create a coupon for a SKU.
|
|
221
|
+
|
|
222
|
+
Since SP-API doesn't support direct coupon creation, this generates
|
|
223
|
+
the coupon specification for use in Seller Central.
|
|
224
|
+
|
|
225
|
+
Examples:
|
|
226
|
+
amz-sp create-coupon PAW2603190101 20
|
|
227
|
+
amz-sp create-coupon PAW2603190101 5 --type fixed --budget 1000
|
|
228
|
+
amz-sp create-coupon PAW2603190101 15 --prime-only --start-date 2026-05-01
|
|
229
|
+
"""
|
|
230
|
+
client = ctx.obj["client"]
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
# Get current listing info
|
|
234
|
+
response = client.get_listing(sku)
|
|
235
|
+
attributes = response.get("attributes", {})
|
|
236
|
+
list_price = attributes.get("list_price", [{}])[0]
|
|
237
|
+
current_price = list_price.get("value", 0)
|
|
238
|
+
|
|
239
|
+
if not current_price:
|
|
240
|
+
click.echo("Error: Could not get current price for SKU", err=True)
|
|
241
|
+
raise click.Abort()
|
|
242
|
+
|
|
243
|
+
# Calculate dates
|
|
244
|
+
from datetime import timedelta
|
|
245
|
+
|
|
246
|
+
start = datetime.strptime(start_date, "%Y-%m-%d") if start_date else datetime.now(timezone.utc)
|
|
247
|
+
end = datetime.strptime(end_date, "%Y-%m-%d") if end_date else start + timedelta(days=30)
|
|
248
|
+
|
|
249
|
+
# Format for Amazon (ISO 8601)
|
|
250
|
+
start_str = start.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
251
|
+
end_str = end.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
252
|
+
|
|
253
|
+
# Calculate discount values
|
|
254
|
+
if coupon_type == "percentage":
|
|
255
|
+
discount_amount = round(current_price * discount / 100, 2)
|
|
256
|
+
discount_display = f"{discount}%"
|
|
257
|
+
else:
|
|
258
|
+
discount_amount = discount
|
|
259
|
+
discount_display = f"${discount}"
|
|
260
|
+
|
|
261
|
+
sale_price = max(0, current_price - discount_amount)
|
|
262
|
+
|
|
263
|
+
# Customer budget defaults to discount amount if not specified
|
|
264
|
+
per_customer = customer_budget or discount_amount
|
|
265
|
+
|
|
266
|
+
# Build coupon specification
|
|
267
|
+
coupon = {
|
|
268
|
+
"coupon_specification": {
|
|
269
|
+
"sku": sku,
|
|
270
|
+
"asin": response.get("summaries", [{}])[0].get("asin"),
|
|
271
|
+
"coupon_type": coupon_type,
|
|
272
|
+
"discount": {
|
|
273
|
+
"percentage" if coupon_type == "percentage" else "fixed_amount": discount,
|
|
274
|
+
"display": discount_display,
|
|
275
|
+
},
|
|
276
|
+
"pricing": {
|
|
277
|
+
"original_price": current_price,
|
|
278
|
+
"discounted_price": sale_price,
|
|
279
|
+
"savings": discount_amount,
|
|
280
|
+
},
|
|
281
|
+
"requirements": {
|
|
282
|
+
"minimum_purchase": min_purchase if min_purchase > 0 else None,
|
|
283
|
+
"prime_only": prime_only,
|
|
284
|
+
"clip_required": clip_coupon,
|
|
285
|
+
},
|
|
286
|
+
"schedule": {
|
|
287
|
+
"start_date": start_str,
|
|
288
|
+
"end_date": end_str,
|
|
289
|
+
"duration_days": (end - start).days,
|
|
290
|
+
},
|
|
291
|
+
"budget": {
|
|
292
|
+
"total_budget": budget,
|
|
293
|
+
"per_customer_max": per_customer,
|
|
294
|
+
"estimated_redemptions": int(budget / discount_amount) if discount_amount > 0 else 0,
|
|
295
|
+
},
|
|
296
|
+
"status": "DRAFT",
|
|
297
|
+
},
|
|
298
|
+
"seller_central_steps": [
|
|
299
|
+
"1. Go to Seller Central → Advertising → Coupons",
|
|
300
|
+
"2. Click 'Create a new coupon'",
|
|
301
|
+
"3. Search for the ASIN or SKU above",
|
|
302
|
+
"4. Select discount type: " + ("Percentage Off" if coupon_type == "percentage" else "Money Off"),
|
|
303
|
+
"5. Enter discount value: " + str(discount),
|
|
304
|
+
"6. Set budget: $" + str(budget),
|
|
305
|
+
"7. Set schedule: " + start_str + " to " + end_str,
|
|
306
|
+
f"8. {'Enable Prime-only targeting' if prime_only else 'Target all customers'}",
|
|
307
|
+
"9. Review and submit",
|
|
308
|
+
],
|
|
309
|
+
"notes": [
|
|
310
|
+
"Coupons typically take 4-8 hours to activate after submission",
|
|
311
|
+
"Amazon charges $0.60 per redemption (US marketplace)",
|
|
312
|
+
"Coupon will display on product detail page and search results",
|
|
313
|
+
f"Estimated cost per redemption: ${discount_amount + 0.60:.2f} (discount + Amazon fee)",
|
|
314
|
+
],
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# Remove None values
|
|
318
|
+
if coupon["coupon_specification"]["requirements"]["minimum_purchase"] is None:
|
|
319
|
+
del coupon["coupon_specification"]["requirements"]["minimum_purchase"]
|
|
320
|
+
|
|
321
|
+
# Output
|
|
322
|
+
output_json = json.dumps(coupon, indent=2)
|
|
323
|
+
|
|
324
|
+
if output:
|
|
325
|
+
output.write(output_json)
|
|
326
|
+
click.echo(f"✓ Coupon specification saved to {output.name}")
|
|
327
|
+
|
|
328
|
+
click.echo(output_json)
|
|
329
|
+
|
|
330
|
+
# Summary
|
|
331
|
+
click.echo("\n" + "=" * 50)
|
|
332
|
+
click.echo("SUMMARY")
|
|
333
|
+
click.echo("=" * 50)
|
|
334
|
+
click.echo(f"SKU: {sku}")
|
|
335
|
+
click.echo(f"Discount: {discount_display}")
|
|
336
|
+
click.echo(f"Price: ${current_price} → ${sale_price}")
|
|
337
|
+
click.echo(f"Budget: ${budget}")
|
|
338
|
+
click.echo(f"Duration: {start_str} to {end_str}")
|
|
339
|
+
click.echo(f"Prime Only: {'Yes' if prime_only else 'No'}")
|
|
340
|
+
click.echo("\n⚠️ SP-API does not support direct coupon creation.")
|
|
341
|
+
click.echo(" Use the Seller Central steps above to create the coupon.")
|
|
342
|
+
|
|
343
|
+
except Exception as e:
|
|
344
|
+
click.echo(f"Error: {e}", err=True)
|
|
345
|
+
raise click.Abort()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@cli.command()
|
|
349
|
+
@click.argument("asin")
|
|
350
|
+
@click.pass_context
|
|
351
|
+
def check_competitors(ctx, asin):
|
|
352
|
+
"""Check competitor pricing for an ASIN."""
|
|
353
|
+
client = ctx.obj["client"]
|
|
354
|
+
try:
|
|
355
|
+
response = client.get_catalog_item(asin)
|
|
356
|
+
attributes = response.get("attributes", {})
|
|
357
|
+
|
|
358
|
+
result = {
|
|
359
|
+
"asin": response.get("asin"),
|
|
360
|
+
"title": attributes.get("item_name", [{}])[0].get("value"),
|
|
361
|
+
"brand": attributes.get("brand", [{}])[0].get("value"),
|
|
362
|
+
"list_price": attributes.get("list_price", [{}])[0].get("value"),
|
|
363
|
+
"sales_rank": response.get("salesRanks", [{}])[0].get("displayRank"),
|
|
364
|
+
}
|
|
365
|
+
click.echo(json.dumps(result, indent=2))
|
|
366
|
+
except Exception as e:
|
|
367
|
+
click.echo(f"Error: {e}", err=True)
|
|
368
|
+
raise click.Abort()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@cli.command()
|
|
372
|
+
@click.pass_context
|
|
373
|
+
def invalidate(ctx):
|
|
374
|
+
"""Invalidate cached access token."""
|
|
375
|
+
ctx.obj["auth"].invalidate()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
cli()
|
|
@@ -4,6 +4,7 @@ README.md
|
|
|
4
4
|
pyproject.toml
|
|
5
5
|
setup.py
|
|
6
6
|
amazon_sp_cli/__init__.py
|
|
7
|
+
amazon_sp_cli/__main__.py
|
|
7
8
|
amazon_sp_cli/auth.py
|
|
8
9
|
amazon_sp_cli/client.py
|
|
9
10
|
amazon_sp_cli/main.py
|
|
@@ -15,4 +16,5 @@ amazon_sp_cli.egg-info/requires.txt
|
|
|
15
16
|
amazon_sp_cli.egg-info/top_level.txt
|
|
16
17
|
tests/__init__.py
|
|
17
18
|
tests/test_auth.py
|
|
18
|
-
tests/test_client.py
|
|
19
|
+
tests/test_client.py
|
|
20
|
+
tests/test_coupon.py
|
|
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="amazon-sp-cli",
|
|
5
|
-
version="0.1.
|
|
5
|
+
version="0.1.2",
|
|
6
6
|
description="CLI tool for Amazon Selling Partner API (SP-API) operations",
|
|
7
7
|
author="Lunan Li",
|
|
8
8
|
author_email="lunan@stellaraether.com",
|
|
@@ -19,6 +19,9 @@ setup(
|
|
|
19
19
|
"amz-sp=amazon_sp_cli.main:cli",
|
|
20
20
|
],
|
|
21
21
|
},
|
|
22
|
+
data_files=[
|
|
23
|
+
("share/amazon-sp-cli", ["README.md"]),
|
|
24
|
+
],
|
|
22
25
|
python_requires=">=3.8",
|
|
23
26
|
classifiers=[
|
|
24
27
|
"Development Status :: 3 - Alpha",
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Tests for SP-API authentication."""
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import tempfile
|
|
6
5
|
from pathlib import Path
|
|
@@ -18,12 +17,14 @@ class TestSPAPIAuth:
|
|
|
18
17
|
def temp_credentials(self):
|
|
19
18
|
"""Create temporary credentials file."""
|
|
20
19
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
|
|
21
|
-
f.write(
|
|
20
|
+
f.write(
|
|
21
|
+
"""
|
|
22
22
|
default:
|
|
23
23
|
refresh_token: "test-refresh-token"
|
|
24
24
|
client_id: "test-client-id"
|
|
25
25
|
client_secret: "test-client-secret"
|
|
26
|
-
"""
|
|
26
|
+
"""
|
|
27
|
+
)
|
|
27
28
|
path = f.name
|
|
28
29
|
yield path
|
|
29
30
|
os.unlink(path)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Tests for coupon command."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import Mock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from click.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from amazon_sp_cli.main import cli
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestCreateCoupon:
|
|
13
|
+
"""Test create-coupon command."""
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def mock_listing_response(self):
|
|
17
|
+
"""Mock listing response with price."""
|
|
18
|
+
return {
|
|
19
|
+
"summaries": [{"asin": "B09BBL8T4Z", "status": ["ACTIVE"]}],
|
|
20
|
+
"attributes": {"list_price": [{"currency": "USD", "value": 29.99}]},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def runner(self):
|
|
25
|
+
"""Create Click test runner."""
|
|
26
|
+
return CliRunner()
|
|
27
|
+
|
|
28
|
+
@patch("amazon_sp_cli.main.SPAPIAuth")
|
|
29
|
+
@patch("amazon_sp_cli.main.SPAPIClient")
|
|
30
|
+
def test_create_coupon_percentage(self, mock_client_class, mock_auth_class, runner, mock_listing_response):
|
|
31
|
+
"""Test creating a percentage coupon."""
|
|
32
|
+
mock_client = Mock()
|
|
33
|
+
mock_client.get_listing.return_value = mock_listing_response
|
|
34
|
+
mock_client_class.return_value = mock_client
|
|
35
|
+
|
|
36
|
+
mock_auth = Mock()
|
|
37
|
+
mock_auth_class.return_value = mock_auth
|
|
38
|
+
|
|
39
|
+
result = runner.invoke(cli, ["create-coupon", "TEST-SKU", "20"])
|
|
40
|
+
|
|
41
|
+
assert result.exit_code == 0
|
|
42
|
+
assert "20.0%" in result.output
|
|
43
|
+
assert "$29.99" in result.output
|
|
44
|
+
assert "$23.99" in result.output # 20% off
|
|
45
|
+
assert "Seller Central" in result.output
|
|
46
|
+
|
|
47
|
+
@patch("amazon_sp_cli.main.SPAPIAuth")
|
|
48
|
+
@patch("amazon_sp_cli.main.SPAPIClient")
|
|
49
|
+
def test_create_coupon_fixed(self, mock_client_class, mock_auth_class, runner, mock_listing_response):
|
|
50
|
+
"""Test creating a fixed amount coupon."""
|
|
51
|
+
mock_client = Mock()
|
|
52
|
+
mock_client.get_listing.return_value = mock_listing_response
|
|
53
|
+
mock_client_class.return_value = mock_client
|
|
54
|
+
|
|
55
|
+
mock_auth = Mock()
|
|
56
|
+
mock_auth_class.return_value = mock_auth
|
|
57
|
+
|
|
58
|
+
result = runner.invoke(cli, ["create-coupon", "TEST-SKU", "5", "--type", "fixed"])
|
|
59
|
+
|
|
60
|
+
assert result.exit_code == 0
|
|
61
|
+
assert "$5" in result.output
|
|
62
|
+
assert "$24.99" in result.output # $29.99 - $5
|
|
63
|
+
|
|
64
|
+
@patch("amazon_sp_cli.main.SPAPIAuth")
|
|
65
|
+
@patch("amazon_sp_cli.main.SPAPIClient")
|
|
66
|
+
def test_create_coupon_with_options(self, mock_client_class, mock_auth_class, runner, mock_listing_response):
|
|
67
|
+
"""Test creating coupon with all options."""
|
|
68
|
+
mock_client = Mock()
|
|
69
|
+
mock_client.get_listing.return_value = mock_listing_response
|
|
70
|
+
mock_client_class.return_value = mock_client
|
|
71
|
+
|
|
72
|
+
mock_auth = Mock()
|
|
73
|
+
mock_auth_class.return_value = mock_auth
|
|
74
|
+
|
|
75
|
+
result = runner.invoke(
|
|
76
|
+
cli,
|
|
77
|
+
[
|
|
78
|
+
"create-coupon",
|
|
79
|
+
"TEST-SKU",
|
|
80
|
+
"15",
|
|
81
|
+
"--prime-only",
|
|
82
|
+
"--budget",
|
|
83
|
+
"1000",
|
|
84
|
+
"--start-date",
|
|
85
|
+
"2026-05-01",
|
|
86
|
+
"--end-date",
|
|
87
|
+
"2026-06-01",
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
assert result.exit_code == 0
|
|
92
|
+
assert "Prime Only: Yes" in result.output
|
|
93
|
+
assert "$1000" in result.output
|
|
94
|
+
assert "2026-05-01" in result.output
|
|
95
|
+
assert "2026-06-01" in result.output
|
|
96
|
+
|
|
97
|
+
@patch("amazon_sp_cli.main.SPAPIAuth")
|
|
98
|
+
@patch("amazon_sp_cli.main.SPAPIClient")
|
|
99
|
+
def test_create_coupon_output_file(self, mock_client_class, mock_auth_class, runner, mock_listing_response):
|
|
100
|
+
"""Test saving coupon to file."""
|
|
101
|
+
mock_client = Mock()
|
|
102
|
+
mock_client.get_listing.return_value = mock_listing_response
|
|
103
|
+
mock_client_class.return_value = mock_client
|
|
104
|
+
|
|
105
|
+
mock_auth = Mock()
|
|
106
|
+
mock_auth_class.return_value = mock_auth
|
|
107
|
+
|
|
108
|
+
with runner.isolated_filesystem():
|
|
109
|
+
result = runner.invoke(cli, ["create-coupon", "TEST-SKU", "10", "-o", "coupon.json"])
|
|
110
|
+
|
|
111
|
+
assert result.exit_code == 0
|
|
112
|
+
assert "saved to" in result.output
|
|
113
|
+
|
|
114
|
+
# Verify file was created
|
|
115
|
+
with open("coupon.json") as f:
|
|
116
|
+
data = json.load(f)
|
|
117
|
+
assert data["coupon_specification"]["sku"] == "TEST-SKU"
|
|
118
|
+
assert data["coupon_specification"]["coupon_type"] == "percentage"
|
|
119
|
+
|
|
120
|
+
@patch("amazon_sp_cli.main.SPAPIAuth")
|
|
121
|
+
@patch("amazon_sp_cli.main.SPAPIClient")
|
|
122
|
+
def test_create_coupon_no_price(self, mock_client_class, mock_auth_class, runner):
|
|
123
|
+
"""Test error when listing has no price."""
|
|
124
|
+
mock_client = Mock()
|
|
125
|
+
mock_client.get_listing.return_value = {
|
|
126
|
+
"summaries": [{"asin": "B09BBL8T4Z"}],
|
|
127
|
+
"attributes": {},
|
|
128
|
+
}
|
|
129
|
+
mock_client_class.return_value = mock_client
|
|
130
|
+
|
|
131
|
+
mock_auth = Mock()
|
|
132
|
+
mock_auth_class.return_value = mock_auth
|
|
133
|
+
|
|
134
|
+
result = runner.invoke(cli, ["create-coupon", "TEST-SKU", "20"])
|
|
135
|
+
|
|
136
|
+
assert result.exit_code != 0
|
|
137
|
+
assert "Error" in result.output
|
|
@@ -1,175 +0,0 @@
|
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|