amazon-sp-cli 0.2.6__tar.gz → 0.2.7__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.2.6/amazon_sp_cli.egg-info → amazon_sp_cli-0.2.7}/PKG-INFO +1 -1
- amazon_sp_cli-0.2.7/amazon_sp_cli/__init__.py +2 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/client.py +69 -2
- amazon_sp_cli-0.2.7/amazon_sp_cli/commands/listings.py +191 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/main.py +2 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7/amazon_sp_cli.egg-info}/PKG-INFO +1 -1
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/SOURCES.txt +2 -0
- amazon_sp_cli-0.2.7/tests/test_listings.py +391 -0
- amazon_sp_cli-0.2.6/amazon_sp_cli/__init__.py +0 -2
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/LICENSE +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/MANIFEST.in +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/README.md +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/__main__.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/auth.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/cli.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/commands/__init__.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/commands/a_plus.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/commands/auth.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/commands/pricing.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/models/__init__.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli/models/a_plus.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/dependency_links.txt +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/entry_points.txt +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/requires.txt +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/top_level.txt +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/pyproject.toml +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/setup.cfg +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/setup.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/tests/__init__.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/tests/test_a_plus.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/tests/test_auth.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/tests/test_client.py +0 -0
- {amazon_sp_cli-0.2.6 → amazon_sp_cli-0.2.7}/tests/test_pricing.py +0 -0
|
@@ -9,6 +9,14 @@ from botocore.awsrequest import AWSRequest
|
|
|
9
9
|
from botocore.credentials import Credentials
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class SPAPIError(Exception):
|
|
13
|
+
"""Raised when SP-API returns an error response."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message, response_body=None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.response_body = response_body
|
|
18
|
+
|
|
19
|
+
|
|
12
20
|
class SPAPIClient:
|
|
13
21
|
"""Client for making signed requests to Amazon SP-API."""
|
|
14
22
|
|
|
@@ -55,7 +63,17 @@ class SPAPIClient:
|
|
|
55
63
|
headers, url, body = self._sign_request(method, path, data)
|
|
56
64
|
|
|
57
65
|
response = requests.request(method, url, headers=headers, data=body)
|
|
58
|
-
|
|
66
|
+
try:
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
except requests.HTTPError as exc:
|
|
69
|
+
try:
|
|
70
|
+
error_body = response.json()
|
|
71
|
+
except ValueError:
|
|
72
|
+
error_body = None
|
|
73
|
+
raise SPAPIError(
|
|
74
|
+
_format_spapi_error(response.status_code, error_body or response.text),
|
|
75
|
+
response_body=error_body,
|
|
76
|
+
) from exc
|
|
59
77
|
|
|
60
78
|
return response.json() if response.text else {}
|
|
61
79
|
|
|
@@ -64,11 +82,41 @@ class SPAPIClient:
|
|
|
64
82
|
path = f"/listings/2021-08-01/items/{self.seller_id}/{sku}"
|
|
65
83
|
params = {
|
|
66
84
|
"marketplaceIds": self.marketplace_id,
|
|
67
|
-
"includedData": "summaries,attributes",
|
|
85
|
+
"includedData": "summaries,attributes,issues,offers,fulfillmentAvailability",
|
|
68
86
|
}
|
|
69
87
|
path += "?" + urlencode(params)
|
|
70
88
|
return self.request("GET", path)
|
|
71
89
|
|
|
90
|
+
def put_listing(
|
|
91
|
+
self, sku: str, product_type: str, attributes: dict, requirements: str = None, mode: str = None
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""Create or fully replace a listing for a SKU."""
|
|
94
|
+
path = f"/listings/2021-08-01/items/{self.seller_id}/{sku}"
|
|
95
|
+
params = {
|
|
96
|
+
"marketplaceIds": self.marketplace_id,
|
|
97
|
+
}
|
|
98
|
+
if mode:
|
|
99
|
+
params["mode"] = mode
|
|
100
|
+
path += "?" + urlencode(params)
|
|
101
|
+
|
|
102
|
+
data = {
|
|
103
|
+
"productType": product_type,
|
|
104
|
+
"attributes": attributes,
|
|
105
|
+
}
|
|
106
|
+
if requirements:
|
|
107
|
+
data["requirements"] = requirements
|
|
108
|
+
|
|
109
|
+
return self.request("PUT", path, data)
|
|
110
|
+
|
|
111
|
+
def delete_listing(self, sku: str) -> dict:
|
|
112
|
+
"""Delete a listing for a SKU."""
|
|
113
|
+
path = f"/listings/2021-08-01/items/{self.seller_id}/{sku}"
|
|
114
|
+
params = {
|
|
115
|
+
"marketplaceIds": self.marketplace_id,
|
|
116
|
+
}
|
|
117
|
+
path += "?" + urlencode(params)
|
|
118
|
+
return self.request("DELETE", path)
|
|
119
|
+
|
|
72
120
|
def update_price(self, sku: str, price: float, mode: str = "VALIDATION_PREVIEW") -> dict:
|
|
73
121
|
"""Update listing price."""
|
|
74
122
|
path = f"/listings/2021-08-01/items/{self.seller_id}/{sku}"
|
|
@@ -189,3 +237,22 @@ class SPAPIClient:
|
|
|
189
237
|
params["fileName"] = file_name
|
|
190
238
|
path += "?" + urlencode(params)
|
|
191
239
|
return self.request("POST", path)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _format_spapi_error(status_code, body):
|
|
243
|
+
"""Format an SP-API error into a human-readable string."""
|
|
244
|
+
if isinstance(body, dict):
|
|
245
|
+
parts = [f"SP-API returned {status_code}"]
|
|
246
|
+
for error in body.get("errors", []):
|
|
247
|
+
code = error.get("code", "Unknown")
|
|
248
|
+
message = error.get("message", "")
|
|
249
|
+
parts.append(f" [{code}] {message}")
|
|
250
|
+
for issue in body.get("issues", []):
|
|
251
|
+
code = issue.get("code", "Unknown")
|
|
252
|
+
message = issue.get("message", "")
|
|
253
|
+
severity = issue.get("severity", "")
|
|
254
|
+
parts.append(f" [{code}] ({severity}) {message}")
|
|
255
|
+
if len(parts) == 1:
|
|
256
|
+
parts.append(f" {json.dumps(body)}")
|
|
257
|
+
return "\n".join(parts)
|
|
258
|
+
return f"SP-API returned {status_code}: {body}"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Listing content management commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..cli import handle_errors
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build_attributes(
|
|
11
|
+
title=None,
|
|
12
|
+
description=None,
|
|
13
|
+
bullet_points=None,
|
|
14
|
+
price=None,
|
|
15
|
+
currency="USD",
|
|
16
|
+
condition=None,
|
|
17
|
+
images=None,
|
|
18
|
+
inventory=None,
|
|
19
|
+
shipping_template=None,
|
|
20
|
+
language_tag="en_US",
|
|
21
|
+
attributes_json=None,
|
|
22
|
+
):
|
|
23
|
+
"""Build SP-API attributes dict from CLI options."""
|
|
24
|
+
attributes = {}
|
|
25
|
+
|
|
26
|
+
if title is not None:
|
|
27
|
+
attributes["item_name"] = [{"value": title, "language_tag": language_tag}]
|
|
28
|
+
|
|
29
|
+
if description is not None:
|
|
30
|
+
attributes["product_description"] = [{"value": description, "language_tag": language_tag}]
|
|
31
|
+
|
|
32
|
+
if bullet_points:
|
|
33
|
+
attributes["bullet_point"] = [{"value": bp} for bp in bullet_points]
|
|
34
|
+
|
|
35
|
+
if price is not None:
|
|
36
|
+
attributes["list_price"] = [{"currency": currency, "value": price}]
|
|
37
|
+
attributes["purchasable_price"] = [{"currency": currency, "value": price}]
|
|
38
|
+
|
|
39
|
+
if condition is not None:
|
|
40
|
+
attributes["condition_type"] = [{"value": condition}]
|
|
41
|
+
|
|
42
|
+
if images:
|
|
43
|
+
attributes["main_product_image_locator"] = [{"media_location": images[0]}]
|
|
44
|
+
for idx, img in enumerate(images[1:], start=1):
|
|
45
|
+
if idx > 8:
|
|
46
|
+
break
|
|
47
|
+
attributes[f"other_product_image_locator_{idx}"] = [{"media_location": img}]
|
|
48
|
+
|
|
49
|
+
if inventory is not None:
|
|
50
|
+
attributes["fulfillment_availability"] = [{"quantity": inventory, "fulfillment_channel_code": "DEFAULT"}]
|
|
51
|
+
|
|
52
|
+
if shipping_template is not None:
|
|
53
|
+
attributes["merchant_shipping_group"] = [{"value": shipping_template}]
|
|
54
|
+
|
|
55
|
+
if attributes_json:
|
|
56
|
+
try:
|
|
57
|
+
extra = json.loads(attributes_json)
|
|
58
|
+
except json.JSONDecodeError as exc:
|
|
59
|
+
raise click.BadParameter(f"Invalid JSON in --attributes-json: {exc}")
|
|
60
|
+
if not isinstance(extra, dict):
|
|
61
|
+
raise click.BadParameter("--attributes-json must be a JSON object")
|
|
62
|
+
attributes.update(extra)
|
|
63
|
+
|
|
64
|
+
return attributes
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_issues(response):
|
|
68
|
+
"""Check response for issues and exit with error if any ERROR severity items exist."""
|
|
69
|
+
issues = response.get("issues", [])
|
|
70
|
+
errors = [i for i in issues if i.get("severity") == "ERROR"]
|
|
71
|
+
if errors:
|
|
72
|
+
click.echo("Validation issues found:", err=True)
|
|
73
|
+
for issue in issues:
|
|
74
|
+
code = issue.get("code", "Unknown")
|
|
75
|
+
message = issue.get("message", "")
|
|
76
|
+
severity = issue.get("severity", "")
|
|
77
|
+
click.echo(f" [{code}] ({severity}) {message}", err=True)
|
|
78
|
+
raise click.Abort()
|
|
79
|
+
return issues
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def register_listings_commands(cli_group, ensure_auth_client):
|
|
83
|
+
"""Register listing management CLI commands."""
|
|
84
|
+
|
|
85
|
+
@cli_group.command()
|
|
86
|
+
@click.argument("sku")
|
|
87
|
+
@click.pass_context
|
|
88
|
+
@handle_errors
|
|
89
|
+
def get_listing(ctx, sku):
|
|
90
|
+
"""Get full listing data for a SKU."""
|
|
91
|
+
_, client = ensure_auth_client(ctx)
|
|
92
|
+
response = client.get_listing(sku)
|
|
93
|
+
click.echo(json.dumps(response, indent=2))
|
|
94
|
+
|
|
95
|
+
@cli_group.command()
|
|
96
|
+
@click.argument("sku")
|
|
97
|
+
@click.option("--product-type", "-p", required=True, help="SP-API product type (e.g., PET_TOY)")
|
|
98
|
+
@click.option(
|
|
99
|
+
"--requirements",
|
|
100
|
+
type=click.Choice(["LISTING", "LISTING_PRODUCT_ONLY", "LISTING_OFFER_ONLY"]),
|
|
101
|
+
default="LISTING",
|
|
102
|
+
help="Requirements level for the update",
|
|
103
|
+
)
|
|
104
|
+
@click.option("--title", help="Item title")
|
|
105
|
+
@click.option("--description", help="Product description")
|
|
106
|
+
@click.option("--bullet-point", multiple=True, help="Bullet point (can be used multiple times)")
|
|
107
|
+
@click.option("--price", type=float, help="List price")
|
|
108
|
+
@click.option("--currency", default="USD", help="Currency code (default USD)")
|
|
109
|
+
@click.option("--condition", help="Condition type (e.g., new_new)")
|
|
110
|
+
@click.option("--image", multiple=True, help="Image URL (first becomes main, rest become other)")
|
|
111
|
+
@click.option("--inventory", type=int, help="Available quantity")
|
|
112
|
+
@click.option("--shipping-template", help="Merchant shipping group name")
|
|
113
|
+
@click.option("--language-tag", default="en_US", help="Language tag for text attributes")
|
|
114
|
+
@click.option(
|
|
115
|
+
"--attributes-json",
|
|
116
|
+
help="Raw JSON string of additional attributes (merged on top of other flags)",
|
|
117
|
+
)
|
|
118
|
+
@click.option("--dry-run", is_flag=True, help="Validate without applying")
|
|
119
|
+
@click.pass_context
|
|
120
|
+
@handle_errors
|
|
121
|
+
def update_listing(
|
|
122
|
+
ctx,
|
|
123
|
+
sku,
|
|
124
|
+
product_type,
|
|
125
|
+
requirements,
|
|
126
|
+
title,
|
|
127
|
+
description,
|
|
128
|
+
bullet_point,
|
|
129
|
+
price,
|
|
130
|
+
currency,
|
|
131
|
+
condition,
|
|
132
|
+
image,
|
|
133
|
+
inventory,
|
|
134
|
+
shipping_template,
|
|
135
|
+
language_tag,
|
|
136
|
+
attributes_json,
|
|
137
|
+
dry_run,
|
|
138
|
+
):
|
|
139
|
+
"""Update listing attributes for a SKU."""
|
|
140
|
+
_, client = ensure_auth_client(ctx)
|
|
141
|
+
|
|
142
|
+
attributes = _build_attributes(
|
|
143
|
+
title=title,
|
|
144
|
+
description=description,
|
|
145
|
+
bullet_points=bullet_point or None,
|
|
146
|
+
price=price,
|
|
147
|
+
currency=currency,
|
|
148
|
+
condition=condition,
|
|
149
|
+
images=image or None,
|
|
150
|
+
inventory=inventory,
|
|
151
|
+
shipping_template=shipping_template,
|
|
152
|
+
language_tag=language_tag,
|
|
153
|
+
attributes_json=attributes_json,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not attributes:
|
|
157
|
+
click.echo("Error: No attributes provided. Use flags or --attributes-json.", err=True)
|
|
158
|
+
raise click.Abort()
|
|
159
|
+
|
|
160
|
+
mode = "VALIDATION_PREVIEW" if dry_run else None
|
|
161
|
+
response = client.put_listing(
|
|
162
|
+
sku,
|
|
163
|
+
product_type=product_type,
|
|
164
|
+
attributes=attributes,
|
|
165
|
+
requirements=requirements,
|
|
166
|
+
mode=mode,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
issues = _check_issues(response)
|
|
170
|
+
if dry_run and issues:
|
|
171
|
+
click.echo("Validation warnings:")
|
|
172
|
+
for issue in issues:
|
|
173
|
+
code = issue.get("code", "Unknown")
|
|
174
|
+
message = issue.get("message", "")
|
|
175
|
+
severity = issue.get("severity", "")
|
|
176
|
+
click.echo(f" [{code}] ({severity}) {message}")
|
|
177
|
+
click.echo("Validation passed with warnings")
|
|
178
|
+
elif dry_run:
|
|
179
|
+
click.echo("Validation passed")
|
|
180
|
+
else:
|
|
181
|
+
click.echo(json.dumps(response, indent=2))
|
|
182
|
+
|
|
183
|
+
@cli_group.command()
|
|
184
|
+
@click.argument("sku")
|
|
185
|
+
@click.pass_context
|
|
186
|
+
@handle_errors
|
|
187
|
+
def delete_listing(ctx, sku):
|
|
188
|
+
"""Delete a listing for a SKU."""
|
|
189
|
+
_, client = ensure_auth_client(ctx)
|
|
190
|
+
response = client.delete_listing(sku)
|
|
191
|
+
click.echo(json.dumps(response, indent=2))
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from .cli import _ensure_auth_client, cli
|
|
4
4
|
from .commands.a_plus import register_a_plus_commands
|
|
5
5
|
from .commands.auth import register_auth_commands
|
|
6
|
+
from .commands.listings import register_listings_commands
|
|
6
7
|
from .commands.pricing import register_pricing_commands
|
|
7
8
|
|
|
8
9
|
register_auth_commands(cli)
|
|
10
|
+
register_listings_commands(cli, _ensure_auth_client)
|
|
9
11
|
register_pricing_commands(cli, _ensure_auth_client)
|
|
10
12
|
register_a_plus_commands(cli, _ensure_auth_client)
|
|
@@ -18,6 +18,7 @@ amazon_sp_cli.egg-info/top_level.txt
|
|
|
18
18
|
amazon_sp_cli/commands/__init__.py
|
|
19
19
|
amazon_sp_cli/commands/a_plus.py
|
|
20
20
|
amazon_sp_cli/commands/auth.py
|
|
21
|
+
amazon_sp_cli/commands/listings.py
|
|
21
22
|
amazon_sp_cli/commands/pricing.py
|
|
22
23
|
amazon_sp_cli/models/__init__.py
|
|
23
24
|
amazon_sp_cli/models/a_plus.py
|
|
@@ -25,4 +26,5 @@ tests/__init__.py
|
|
|
25
26
|
tests/test_a_plus.py
|
|
26
27
|
tests/test_auth.py
|
|
27
28
|
tests/test_client.py
|
|
29
|
+
tests/test_listings.py
|
|
28
30
|
tests/test_pricing.py
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Tests for listing commands."""
|
|
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 TestGetListing:
|
|
13
|
+
"""Test get-listing command."""
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def mock_listing_response(self):
|
|
17
|
+
"""Mock full listing response."""
|
|
18
|
+
return {
|
|
19
|
+
"summaries": [{"asin": "B09BBL8T4Z", "status": ["ACTIVE"]}],
|
|
20
|
+
"attributes": {
|
|
21
|
+
"item_name": [{"value": "Test Product", "language_tag": "en_US"}],
|
|
22
|
+
"list_price": [{"currency": "USD", "value": 29.99}],
|
|
23
|
+
},
|
|
24
|
+
"issues": [],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def runner(self):
|
|
29
|
+
"""Create Click test runner."""
|
|
30
|
+
return CliRunner()
|
|
31
|
+
|
|
32
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
33
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
34
|
+
def test_get_listing(self, mock_client_class, mock_auth_class, runner, mock_listing_response):
|
|
35
|
+
"""Test fetching a listing."""
|
|
36
|
+
mock_client = Mock()
|
|
37
|
+
mock_client.get_listing.return_value = mock_listing_response
|
|
38
|
+
mock_client_class.return_value = mock_client
|
|
39
|
+
|
|
40
|
+
mock_auth = Mock()
|
|
41
|
+
mock_auth_class.return_value = mock_auth
|
|
42
|
+
|
|
43
|
+
result = runner.invoke(cli, ["get-listing", "TEST-SKU"])
|
|
44
|
+
|
|
45
|
+
assert result.exit_code == 0
|
|
46
|
+
output = json.loads(result.output)
|
|
47
|
+
assert output["summaries"][0]["asin"] == "B09BBL8T4Z"
|
|
48
|
+
assert output["attributes"]["item_name"][0]["value"] == "Test Product"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestUpdateListing:
|
|
52
|
+
"""Test update-listing command."""
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def runner(self):
|
|
56
|
+
"""Create Click test runner."""
|
|
57
|
+
return CliRunner()
|
|
58
|
+
|
|
59
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
60
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
61
|
+
def test_update_listing_with_title(self, mock_client_class, mock_auth_class, runner):
|
|
62
|
+
"""Test updating a listing title."""
|
|
63
|
+
mock_client = Mock()
|
|
64
|
+
mock_client.put_listing.return_value = {"sku": "TEST-SKU", "status": "ACCEPTED"}
|
|
65
|
+
mock_client_class.return_value = mock_client
|
|
66
|
+
|
|
67
|
+
mock_auth = Mock()
|
|
68
|
+
mock_auth_class.return_value = mock_auth
|
|
69
|
+
|
|
70
|
+
result = runner.invoke(
|
|
71
|
+
cli,
|
|
72
|
+
[
|
|
73
|
+
"update-listing",
|
|
74
|
+
"TEST-SKU",
|
|
75
|
+
"--product-type",
|
|
76
|
+
"PET_TOY",
|
|
77
|
+
"--title",
|
|
78
|
+
"New Title",
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
assert result.exit_code == 0
|
|
83
|
+
mock_client.put_listing.assert_called_once()
|
|
84
|
+
call_args = mock_client.put_listing.call_args
|
|
85
|
+
assert call_args.kwargs["product_type"] == "PET_TOY"
|
|
86
|
+
assert call_args.kwargs["attributes"]["item_name"] == [{"value": "New Title", "language_tag": "en_US"}]
|
|
87
|
+
|
|
88
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
89
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
90
|
+
def test_update_listing_with_multiple_flags(self, mock_client_class, mock_auth_class, runner):
|
|
91
|
+
"""Test updating multiple attributes at once."""
|
|
92
|
+
mock_client = Mock()
|
|
93
|
+
mock_client.put_listing.return_value = {"sku": "TEST-SKU", "status": "ACCEPTED"}
|
|
94
|
+
mock_client_class.return_value = mock_client
|
|
95
|
+
|
|
96
|
+
mock_auth = Mock()
|
|
97
|
+
mock_auth_class.return_value = mock_auth
|
|
98
|
+
|
|
99
|
+
result = runner.invoke(
|
|
100
|
+
cli,
|
|
101
|
+
[
|
|
102
|
+
"update-listing",
|
|
103
|
+
"TEST-SKU",
|
|
104
|
+
"--product-type",
|
|
105
|
+
"PET_TOY",
|
|
106
|
+
"--title",
|
|
107
|
+
"New Title",
|
|
108
|
+
"--description",
|
|
109
|
+
"New Description",
|
|
110
|
+
"--bullet-point",
|
|
111
|
+
"Point 1",
|
|
112
|
+
"--bullet-point",
|
|
113
|
+
"Point 2",
|
|
114
|
+
"--price",
|
|
115
|
+
"19.99",
|
|
116
|
+
"--condition",
|
|
117
|
+
"new_new",
|
|
118
|
+
"--inventory",
|
|
119
|
+
"100",
|
|
120
|
+
"--shipping-template",
|
|
121
|
+
"Std Template",
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
assert result.exit_code == 0
|
|
126
|
+
attrs = mock_client.put_listing.call_args.kwargs["attributes"]
|
|
127
|
+
assert attrs["item_name"] == [{"value": "New Title", "language_tag": "en_US"}]
|
|
128
|
+
assert attrs["product_description"] == [{"value": "New Description", "language_tag": "en_US"}]
|
|
129
|
+
assert attrs["bullet_point"] == [{"value": "Point 1"}, {"value": "Point 2"}]
|
|
130
|
+
assert attrs["list_price"] == [{"currency": "USD", "value": 19.99}]
|
|
131
|
+
assert attrs["purchasable_price"] == [{"currency": "USD", "value": 19.99}]
|
|
132
|
+
assert attrs["condition_type"] == [{"value": "new_new"}]
|
|
133
|
+
assert attrs["fulfillment_availability"] == [{"quantity": 100, "fulfillment_channel_code": "DEFAULT"}]
|
|
134
|
+
assert attrs["merchant_shipping_group"] == [{"value": "Std Template"}]
|
|
135
|
+
|
|
136
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
137
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
138
|
+
def test_update_listing_with_images(self, mock_client_class, mock_auth_class, runner):
|
|
139
|
+
"""Test updating images."""
|
|
140
|
+
mock_client = Mock()
|
|
141
|
+
mock_client.put_listing.return_value = {"sku": "TEST-SKU", "status": "ACCEPTED"}
|
|
142
|
+
mock_client_class.return_value = mock_client
|
|
143
|
+
|
|
144
|
+
mock_auth = Mock()
|
|
145
|
+
mock_auth_class.return_value = mock_auth
|
|
146
|
+
|
|
147
|
+
result = runner.invoke(
|
|
148
|
+
cli,
|
|
149
|
+
[
|
|
150
|
+
"update-listing",
|
|
151
|
+
"TEST-SKU",
|
|
152
|
+
"--product-type",
|
|
153
|
+
"PET_TOY",
|
|
154
|
+
"--image",
|
|
155
|
+
"https://example.com/main.jpg",
|
|
156
|
+
"--image",
|
|
157
|
+
"https://example.com/other1.jpg",
|
|
158
|
+
"--image",
|
|
159
|
+
"https://example.com/other2.jpg",
|
|
160
|
+
],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
assert result.exit_code == 0
|
|
164
|
+
attrs = mock_client.put_listing.call_args.kwargs["attributes"]
|
|
165
|
+
assert attrs["main_product_image_locator"] == [{"media_location": "https://example.com/main.jpg"}]
|
|
166
|
+
assert attrs["other_product_image_locator_1"] == [{"media_location": "https://example.com/other1.jpg"}]
|
|
167
|
+
assert attrs["other_product_image_locator_2"] == [{"media_location": "https://example.com/other2.jpg"}]
|
|
168
|
+
|
|
169
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
170
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
171
|
+
def test_update_listing_with_attributes_json(self, mock_client_class, mock_auth_class, runner):
|
|
172
|
+
"""Test updating with raw attributes JSON."""
|
|
173
|
+
mock_client = Mock()
|
|
174
|
+
mock_client.put_listing.return_value = {"sku": "TEST-SKU", "status": "ACCEPTED"}
|
|
175
|
+
mock_client_class.return_value = mock_client
|
|
176
|
+
|
|
177
|
+
mock_auth = Mock()
|
|
178
|
+
mock_auth_class.return_value = mock_auth
|
|
179
|
+
|
|
180
|
+
result = runner.invoke(
|
|
181
|
+
cli,
|
|
182
|
+
[
|
|
183
|
+
"update-listing",
|
|
184
|
+
"TEST-SKU",
|
|
185
|
+
"--product-type",
|
|
186
|
+
"PET_TOY",
|
|
187
|
+
"--attributes-json",
|
|
188
|
+
'{"item_name": [{"value": "JSON Title", "language_tag": "en_US"}]}',
|
|
189
|
+
],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
assert result.exit_code == 0
|
|
193
|
+
attrs = mock_client.put_listing.call_args.kwargs["attributes"]
|
|
194
|
+
assert attrs["item_name"] == [{"value": "JSON Title", "language_tag": "en_US"}]
|
|
195
|
+
|
|
196
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
197
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
198
|
+
def test_update_listing_flags_override_json(self, mock_client_class, mock_auth_class, runner):
|
|
199
|
+
"""Test that CLI flags override --attributes-json for same keys."""
|
|
200
|
+
mock_client = Mock()
|
|
201
|
+
mock_client.put_listing.return_value = {"sku": "TEST-SKU", "status": "ACCEPTED"}
|
|
202
|
+
mock_client_class.return_value = mock_client
|
|
203
|
+
|
|
204
|
+
mock_auth = Mock()
|
|
205
|
+
mock_auth_class.return_value = mock_auth
|
|
206
|
+
|
|
207
|
+
result = runner.invoke(
|
|
208
|
+
cli,
|
|
209
|
+
[
|
|
210
|
+
"update-listing",
|
|
211
|
+
"TEST-SKU",
|
|
212
|
+
"--product-type",
|
|
213
|
+
"PET_TOY",
|
|
214
|
+
"--title",
|
|
215
|
+
"Flag Title",
|
|
216
|
+
"--attributes-json",
|
|
217
|
+
'{"item_name": [{"value": "JSON Title", "language_tag": "en_US"}]}',
|
|
218
|
+
],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
assert result.exit_code == 0
|
|
222
|
+
attrs = mock_client.put_listing.call_args.kwargs["attributes"]
|
|
223
|
+
# flags are built first, then attributes_json is merged on top
|
|
224
|
+
assert attrs["item_name"] == [{"value": "JSON Title", "language_tag": "en_US"}]
|
|
225
|
+
|
|
226
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
227
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
228
|
+
def test_update_listing_dry_run(self, mock_client_class, mock_auth_class, runner):
|
|
229
|
+
"""Test dry-run mode."""
|
|
230
|
+
mock_client = Mock()
|
|
231
|
+
mock_client.put_listing.return_value = {"sku": "TEST-SKU", "issues": []}
|
|
232
|
+
mock_client_class.return_value = mock_client
|
|
233
|
+
|
|
234
|
+
mock_auth = Mock()
|
|
235
|
+
mock_auth_class.return_value = mock_auth
|
|
236
|
+
|
|
237
|
+
result = runner.invoke(
|
|
238
|
+
cli,
|
|
239
|
+
[
|
|
240
|
+
"update-listing",
|
|
241
|
+
"TEST-SKU",
|
|
242
|
+
"--product-type",
|
|
243
|
+
"PET_TOY",
|
|
244
|
+
"--title",
|
|
245
|
+
"New Title",
|
|
246
|
+
"--dry-run",
|
|
247
|
+
],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
assert result.exit_code == 0
|
|
251
|
+
assert "Validation passed" in result.output
|
|
252
|
+
assert mock_client.put_listing.call_args.kwargs["mode"] == "VALIDATION_PREVIEW"
|
|
253
|
+
|
|
254
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
255
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
256
|
+
def test_update_listing_with_issues(self, mock_client_class, mock_auth_class, runner):
|
|
257
|
+
"""Test that ERROR issues cause failure."""
|
|
258
|
+
mock_client = Mock()
|
|
259
|
+
mock_client.put_listing.return_value = {
|
|
260
|
+
"sku": "TEST-SKU",
|
|
261
|
+
"issues": [
|
|
262
|
+
{
|
|
263
|
+
"code": "99022",
|
|
264
|
+
"message": "Invalid attribute",
|
|
265
|
+
"severity": "ERROR",
|
|
266
|
+
}
|
|
267
|
+
],
|
|
268
|
+
}
|
|
269
|
+
mock_client_class.return_value = mock_client
|
|
270
|
+
|
|
271
|
+
mock_auth = Mock()
|
|
272
|
+
mock_auth_class.return_value = mock_auth
|
|
273
|
+
|
|
274
|
+
result = runner.invoke(
|
|
275
|
+
cli,
|
|
276
|
+
[
|
|
277
|
+
"update-listing",
|
|
278
|
+
"TEST-SKU",
|
|
279
|
+
"--product-type",
|
|
280
|
+
"PET_TOY",
|
|
281
|
+
"--title",
|
|
282
|
+
"New Title",
|
|
283
|
+
],
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
assert result.exit_code != 0
|
|
287
|
+
assert "Validation issues found" in result.output
|
|
288
|
+
|
|
289
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
290
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
291
|
+
def test_update_listing_no_attributes(self, mock_client_class, mock_auth_class, runner):
|
|
292
|
+
"""Test error when no attributes are provided."""
|
|
293
|
+
mock_client = Mock()
|
|
294
|
+
mock_client_class.return_value = mock_client
|
|
295
|
+
|
|
296
|
+
mock_auth = Mock()
|
|
297
|
+
mock_auth_class.return_value = mock_auth
|
|
298
|
+
|
|
299
|
+
result = runner.invoke(
|
|
300
|
+
cli,
|
|
301
|
+
[
|
|
302
|
+
"update-listing",
|
|
303
|
+
"TEST-SKU",
|
|
304
|
+
"--product-type",
|
|
305
|
+
"PET_TOY",
|
|
306
|
+
],
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
assert result.exit_code != 0
|
|
310
|
+
assert "No attributes provided" in result.output
|
|
311
|
+
|
|
312
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
313
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
314
|
+
def test_update_listing_invalid_json(self, mock_client_class, mock_auth_class, runner):
|
|
315
|
+
"""Test error with invalid attributes JSON."""
|
|
316
|
+
mock_client = Mock()
|
|
317
|
+
mock_client_class.return_value = mock_client
|
|
318
|
+
|
|
319
|
+
mock_auth = Mock()
|
|
320
|
+
mock_auth_class.return_value = mock_auth
|
|
321
|
+
|
|
322
|
+
result = runner.invoke(
|
|
323
|
+
cli,
|
|
324
|
+
[
|
|
325
|
+
"update-listing",
|
|
326
|
+
"TEST-SKU",
|
|
327
|
+
"--product-type",
|
|
328
|
+
"PET_TOY",
|
|
329
|
+
"--attributes-json",
|
|
330
|
+
"not json",
|
|
331
|
+
],
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
assert result.exit_code != 0
|
|
335
|
+
assert "Invalid JSON" in result.output
|
|
336
|
+
|
|
337
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
338
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
339
|
+
def test_update_listing_requirements_option(self, mock_client_class, mock_auth_class, runner):
|
|
340
|
+
"""Test requirements option is passed through."""
|
|
341
|
+
mock_client = Mock()
|
|
342
|
+
mock_client.put_listing.return_value = {"sku": "TEST-SKU", "status": "ACCEPTED"}
|
|
343
|
+
mock_client_class.return_value = mock_client
|
|
344
|
+
|
|
345
|
+
mock_auth = Mock()
|
|
346
|
+
mock_auth_class.return_value = mock_auth
|
|
347
|
+
|
|
348
|
+
result = runner.invoke(
|
|
349
|
+
cli,
|
|
350
|
+
[
|
|
351
|
+
"update-listing",
|
|
352
|
+
"TEST-SKU",
|
|
353
|
+
"--product-type",
|
|
354
|
+
"PET_TOY",
|
|
355
|
+
"--requirements",
|
|
356
|
+
"LISTING_OFFER_ONLY",
|
|
357
|
+
"--price",
|
|
358
|
+
"9.99",
|
|
359
|
+
],
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
assert result.exit_code == 0
|
|
363
|
+
call_args = mock_client.put_listing.call_args
|
|
364
|
+
assert call_args.kwargs["requirements"] == "LISTING_OFFER_ONLY"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class TestDeleteListing:
|
|
368
|
+
"""Test delete-listing command."""
|
|
369
|
+
|
|
370
|
+
@pytest.fixture
|
|
371
|
+
def runner(self):
|
|
372
|
+
"""Create Click test runner."""
|
|
373
|
+
return CliRunner()
|
|
374
|
+
|
|
375
|
+
@patch("amazon_sp_cli.cli.SPAPIAuth")
|
|
376
|
+
@patch("amazon_sp_cli.cli.SPAPIClient")
|
|
377
|
+
def test_delete_listing(self, mock_client_class, mock_auth_class, runner):
|
|
378
|
+
"""Test deleting a listing."""
|
|
379
|
+
mock_client = Mock()
|
|
380
|
+
mock_client.delete_listing.return_value = {"sku": "TEST-SKU", "status": "ACCEPTED"}
|
|
381
|
+
mock_client_class.return_value = mock_client
|
|
382
|
+
|
|
383
|
+
mock_auth = Mock()
|
|
384
|
+
mock_auth_class.return_value = mock_auth
|
|
385
|
+
|
|
386
|
+
result = runner.invoke(cli, ["delete-listing", "TEST-SKU"])
|
|
387
|
+
|
|
388
|
+
assert result.exit_code == 0
|
|
389
|
+
mock_client.delete_listing.assert_called_once_with("TEST-SKU")
|
|
390
|
+
output = json.loads(result.output)
|
|
391
|
+
assert output["status"] == "ACCEPTED"
|
|
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
|
|
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
|