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.
@@ -0,0 +1,2 @@
1
+ # Amazon SP-API CLI
2
+ __version__ = "0.1.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.")
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ amz-sp = amazon_sp_cli.main:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stellar Aether
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ amazon_sp_cli
2
+ tests
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()