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.
Files changed (23) hide show
  1. {amazon_sp_cli-0.1.1/amazon_sp_cli.egg-info → amazon_sp_cli-0.1.2}/PKG-INFO +1 -1
  2. amazon_sp_cli-0.1.2/amazon_sp_cli/__main__.py +6 -0
  3. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli/auth.py +9 -7
  4. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli/client.py +0 -1
  5. amazon_sp_cli-0.1.2/amazon_sp_cli/main.py +379 -0
  6. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2/amazon_sp_cli.egg-info}/PKG-INFO +1 -1
  7. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/SOURCES.txt +3 -1
  8. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/setup.py +4 -1
  9. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/tests/test_auth.py +4 -3
  10. amazon_sp_cli-0.1.2/tests/test_coupon.py +137 -0
  11. amazon_sp_cli-0.1.1/amazon_sp_cli/main.py +0 -175
  12. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/LICENSE +0 -0
  13. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/MANIFEST.in +0 -0
  14. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/README.md +0 -0
  15. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli/__init__.py +0 -0
  16. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/dependency_links.txt +0 -0
  17. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/entry_points.txt +0 -0
  18. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/requires.txt +0 -0
  19. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/amazon_sp_cli.egg-info/top_level.txt +0 -0
  20. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/pyproject.toml +0 -0
  21. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/setup.cfg +0 -0
  22. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/tests/__init__.py +0 -0
  23. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.2}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-sp-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: CLI tool for Amazon Selling Partner API (SP-API) operations
5
5
  Home-page: https://github.com/stellaraether/amazon-sp-cli
6
6
  Author: Lunan Li
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m amazon_sp_cli."""
2
+
3
+ from amazon_sp_cli.main import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -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-pricing" / "token-cache.json"
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-pricing" / "credentials.yml"
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
- "access_token": None,
100
- "expires_at": 0,
101
- "refreshed_at": None,
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.")
@@ -7,7 +7,6 @@ import requests
7
7
  from botocore.auth import SigV4Auth
8
8
  from botocore.awsrequest import AWSRequest
9
9
  from botocore.credentials import Credentials
10
- from botocore.session import Session
11
10
 
12
11
 
13
12
  class SPAPIClient:
@@ -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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-sp-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: CLI tool for Amazon Selling Partner API (SP-API) operations
5
5
  Home-page: https://github.com/stellaraether/amazon-sp-cli
6
6
  Author: Lunan Li
@@ -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.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