amazon-sp-cli 0.1.1__tar.gz → 0.1.3__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 (22) hide show
  1. {amazon_sp_cli-0.1.1/amazon_sp_cli.egg-info → amazon_sp_cli-0.1.3}/PKG-INFO +1 -1
  2. amazon_sp_cli-0.1.3/amazon_sp_cli/__main__.py +6 -0
  3. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/amazon_sp_cli/auth.py +9 -7
  4. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/amazon_sp_cli/client.py +0 -1
  5. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/amazon_sp_cli/main.py +169 -0
  6. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3/amazon_sp_cli.egg-info}/PKG-INFO +1 -1
  7. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/amazon_sp_cli.egg-info/SOURCES.txt +3 -1
  8. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/setup.py +4 -1
  9. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/tests/test_auth.py +4 -3
  10. amazon_sp_cli-0.1.3/tests/test_sale_price.py +132 -0
  11. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/LICENSE +0 -0
  12. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/MANIFEST.in +0 -0
  13. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/README.md +0 -0
  14. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/amazon_sp_cli/__init__.py +0 -0
  15. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/amazon_sp_cli.egg-info/dependency_links.txt +0 -0
  16. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/amazon_sp_cli.egg-info/entry_points.txt +0 -0
  17. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/amazon_sp_cli.egg-info/requires.txt +0 -0
  18. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/amazon_sp_cli.egg-info/top_level.txt +0 -0
  19. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/pyproject.toml +0 -0
  20. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/setup.cfg +0 -0
  21. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/tests/__init__.py +0 -0
  22. {amazon_sp_cli-0.1.1 → amazon_sp_cli-0.1.3}/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.3
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:
@@ -9,11 +9,54 @@ from .auth import SPAPIAuth
9
9
  from .client import SPAPIClient
10
10
 
11
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
+
12
54
  @click.group()
13
55
  @click.option("--credentials", "-c", help="Path to credentials YAML file")
14
56
  @click.pass_context
15
57
  def cli(ctx, credentials):
16
58
  """Amazon SP-API CLI - Manage listings, pricing, inventory, and more."""
59
+ _check_path()
17
60
  ctx.ensure_object(dict)
18
61
  ctx.obj["auth"] = SPAPIAuth(credentials)
19
62
  ctx.obj["client"] = SPAPIClient(ctx.obj["auth"])
@@ -141,6 +184,132 @@ def create_discount(ctx, sku, percent, all_variations):
141
184
  raise click.Abort()
142
185
 
143
186
 
187
+ @cli.command()
188
+ @click.argument("sku")
189
+ @click.argument("discount", type=float)
190
+ @click.option(
191
+ "--type",
192
+ "discount_type",
193
+ type=click.Choice(["percentage", "fixed"]),
194
+ default="percentage",
195
+ help="Discount type: percentage or fixed amount off",
196
+ )
197
+ @click.option("--start-date", help="Start date (YYYY-MM-DD). Defaults to today")
198
+ @click.option("--end-date", help="End date (YYYY-MM-DD). Defaults to 30 days from start")
199
+ @click.option("--output", "-o", type=click.File("w"), help="Save price adjustment data to file")
200
+ @click.pass_context
201
+ def sale_price(
202
+ ctx,
203
+ sku,
204
+ discount,
205
+ discount_type,
206
+ start_date,
207
+ end_date,
208
+ output,
209
+ ):
210
+ """Generate sale price data for a SKU.
211
+
212
+ SP-API does not support direct sale price creation. This generates
213
+ the feed data you can submit via the Feeds API or use in Seller Central.
214
+
215
+ Examples:
216
+ amz-sp sale-price PAW2603190101 20
217
+ amz-sp sale-price PAW2603190101 5 --type fixed
218
+ amz-sp sale-price PAW2603190101 15 --start-date 2026-05-01 --end-date 2026-05-31
219
+ """
220
+ client = ctx.obj["client"]
221
+
222
+ try:
223
+ # Get current listing info
224
+ response = client.get_listing(sku)
225
+ attributes = response.get("attributes", {})
226
+ list_price = attributes.get("list_price", [{}])[0]
227
+ current_price = list_price.get("value", 0)
228
+
229
+ if not current_price:
230
+ click.echo("Error: Could not get current price for SKU", err=True)
231
+ raise click.Abort()
232
+
233
+ # Calculate dates
234
+ from datetime import timedelta
235
+
236
+ start = datetime.strptime(start_date, "%Y-%m-%d") if start_date else datetime.now(timezone.utc)
237
+ end = datetime.strptime(end_date, "%Y-%m-%d") if end_date else start + timedelta(days=30)
238
+
239
+ # Format for Amazon (ISO 8601)
240
+ start_str = start.strftime("%Y-%m-%dT%H:%M:%SZ")
241
+ end_str = end.strftime("%Y-%m-%dT%H:%M:%SZ")
242
+
243
+ # Calculate discount values
244
+ if discount_type == "percentage":
245
+ discount_amount = round(current_price * discount / 100, 2)
246
+ discount_display = f"{discount}%"
247
+ else:
248
+ discount_amount = discount
249
+ discount_display = f"${discount}"
250
+
251
+ new_sale_price = max(0, current_price - discount_amount)
252
+
253
+ # Build sale price feed data
254
+ feed = {
255
+ "sku": sku,
256
+ "asin": response.get("summaries", [{}])[0].get("asin"),
257
+ "pricing": {
258
+ "original_price": current_price,
259
+ "sale_price": new_sale_price,
260
+ "discount_amount": discount_amount,
261
+ "discount_display": discount_display,
262
+ },
263
+ "schedule": {
264
+ "start_date": start_str,
265
+ "end_date": end_str,
266
+ "duration_days": (end - start).days,
267
+ },
268
+ "feed_data": {
269
+ "messageId": 1,
270
+ "sku": sku,
271
+ "operationType": "PARTIAL_UPDATE",
272
+ "productType": "PET_TOY",
273
+ "attributes": {
274
+ "list_price": [{"currency": "USD", "value": current_price}],
275
+ "sale_price": [
276
+ {
277
+ "currency": "USD",
278
+ "value": new_sale_price,
279
+ "effective_date": start_str,
280
+ "end_date": end_str,
281
+ }
282
+ ],
283
+ },
284
+ },
285
+ }
286
+
287
+ # Output
288
+ output_json = json.dumps(feed, indent=2)
289
+
290
+ if output:
291
+ output.write(output_json)
292
+ click.echo(f"✓ Sale price data saved to {output.name}")
293
+
294
+ click.echo(output_json)
295
+
296
+ # Summary
297
+ click.echo("\n" + "=" * 50)
298
+ click.echo("SALE PRICE SUMMARY")
299
+ click.echo("=" * 50)
300
+ click.echo(f"SKU: {sku}")
301
+ click.echo(f"Discount: {discount_display}")
302
+ click.echo(f"Price: ${current_price} → ${new_sale_price}")
303
+ click.echo(f"Duration: {start_str} to {end_str}")
304
+ click.echo("\n⚠️ SP-API does not support direct sale price creation.")
305
+ click.echo(" Submit the feed_data above via the Feeds API")
306
+ click.echo(" or update manually in Seller Central.")
307
+
308
+ except Exception as e:
309
+ click.echo(f"Error: {e}", err=True)
310
+ raise click.Abort()
311
+
312
+
144
313
  @cli.command()
145
314
  @click.argument("asin")
146
315
  @click.pass_context
@@ -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.3
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_sale_price.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.3",
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,132 @@
1
+ """Tests for sale-price 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 TestSalePrice:
13
+ """Test sale-price 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_sale_price_percentage(self, mock_client_class, mock_auth_class, runner, mock_listing_response):
31
+ """Test generating percentage sale price."""
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, ["sale-price", "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
45
+ assert "feed_data" in result.output
46
+
47
+ @patch("amazon_sp_cli.main.SPAPIAuth")
48
+ @patch("amazon_sp_cli.main.SPAPIClient")
49
+ def test_sale_price_fixed(self, mock_client_class, mock_auth_class, runner, mock_listing_response):
50
+ """Test generating fixed amount sale price."""
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, ["sale-price", "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
63
+
64
+ @patch("amazon_sp_cli.main.SPAPIAuth")
65
+ @patch("amazon_sp_cli.main.SPAPIClient")
66
+ def test_sale_price_with_dates(self, mock_client_class, mock_auth_class, runner, mock_listing_response):
67
+ """Test generating sale price with custom dates."""
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
+ "sale-price",
79
+ "TEST-SKU",
80
+ "15",
81
+ "--start-date",
82
+ "2026-05-01",
83
+ "--end-date",
84
+ "2026-06-01",
85
+ ],
86
+ )
87
+
88
+ assert result.exit_code == 0
89
+ assert "2026-05-01" in result.output
90
+ assert "2026-06-01" in result.output
91
+
92
+ @patch("amazon_sp_cli.main.SPAPIAuth")
93
+ @patch("amazon_sp_cli.main.SPAPIClient")
94
+ def test_sale_price_output_file(self, mock_client_class, mock_auth_class, runner, mock_listing_response):
95
+ """Test saving sale price data to file."""
96
+ mock_client = Mock()
97
+ mock_client.get_listing.return_value = mock_listing_response
98
+ mock_client_class.return_value = mock_client
99
+
100
+ mock_auth = Mock()
101
+ mock_auth_class.return_value = mock_auth
102
+
103
+ with runner.isolated_filesystem():
104
+ result = runner.invoke(cli, ["sale-price", "TEST-SKU", "10", "-o", "sale-price.json"])
105
+
106
+ assert result.exit_code == 0
107
+ assert "saved to" in result.output
108
+
109
+ # Verify file was created
110
+ with open("sale-price.json") as f:
111
+ data = json.load(f)
112
+ assert data["sku"] == "TEST-SKU"
113
+ assert data["pricing"]["discount_display"] == "10.0%"
114
+
115
+ @patch("amazon_sp_cli.main.SPAPIAuth")
116
+ @patch("amazon_sp_cli.main.SPAPIClient")
117
+ def test_sale_price_no_price(self, mock_client_class, mock_auth_class, runner):
118
+ """Test error when listing has no price."""
119
+ mock_client = Mock()
120
+ mock_client.get_listing.return_value = {
121
+ "summaries": [{"asin": "B09BBL8T4Z"}],
122
+ "attributes": {},
123
+ }
124
+ mock_client_class.return_value = mock_client
125
+
126
+ mock_auth = Mock()
127
+ mock_auth_class.return_value = mock_auth
128
+
129
+ result = runner.invoke(cli, ["sale-price", "TEST-SKU", "20"])
130
+
131
+ assert result.exit_code != 0
132
+ assert "Error" in result.output
File without changes
File without changes
File without changes
File without changes