amazon-sp-cli 0.2.5__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.5/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.5 → 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.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/main.py +2 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/models/a_plus.py +45 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7/amazon_sp_cli.egg-info}/PKG-INFO +1 -1
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/SOURCES.txt +2 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/tests/test_a_plus.py +15 -0
- amazon_sp_cli-0.2.7/tests/test_listings.py +391 -0
- amazon_sp_cli-0.2.5/amazon_sp_cli/__init__.py +0 -2
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/LICENSE +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/MANIFEST.in +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/README.md +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/__main__.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/auth.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/cli.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/commands/__init__.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/commands/a_plus.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/commands/auth.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/commands/pricing.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli/models/__init__.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/dependency_links.txt +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/entry_points.txt +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/requires.txt +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/amazon_sp_cli.egg-info/top_level.txt +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/pyproject.toml +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/setup.cfg +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/setup.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/tests/__init__.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/tests/test_auth.py +0 -0
- {amazon_sp_cli-0.2.5 → amazon_sp_cli-0.2.7}/tests/test_client.py +0 -0
- {amazon_sp_cli-0.2.5 → 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)
|
|
@@ -76,6 +76,26 @@ class StandardSingleImageModule:
|
|
|
76
76
|
return result
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
class StandardSingleSideImageModule:
|
|
80
|
+
"""Standard Single Side Image module."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, image=None, description=None, image_position_type="LEFT"):
|
|
83
|
+
self.image = image
|
|
84
|
+
self.description = description
|
|
85
|
+
self.image_position_type = image_position_type
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
result = {"imagePositionType": self.image_position_type}
|
|
89
|
+
block = {}
|
|
90
|
+
if self.image:
|
|
91
|
+
block["image"] = self.image.to_dict()
|
|
92
|
+
if self.description:
|
|
93
|
+
block["description"] = self.description.to_dict()
|
|
94
|
+
if block:
|
|
95
|
+
result["block"] = block
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
79
99
|
class StandardMultipleImageTextModule:
|
|
80
100
|
"""Standard Multiple Image & Text module."""
|
|
81
101
|
|
|
@@ -192,6 +212,7 @@ class ContentModule:
|
|
|
192
212
|
MODULE_TYPES = {
|
|
193
213
|
"STANDARD_IMAGE_TEXT": "standardImageTextOverlay",
|
|
194
214
|
"STANDARD_SINGLE_IMAGE": "standardSingleImage",
|
|
215
|
+
"STANDARD_SINGLE_SIDE_IMAGE": "standardSingleSideImage",
|
|
195
216
|
"STANDARD_MULTIPLE_IMAGE_TEXT": "standardMultipleImageText",
|
|
196
217
|
"STANDARD_FOUR_IMAGE_TEXT": "standardFourImageText",
|
|
197
218
|
"STANDARD_COMPARISON_TABLE": "standardComparisonTable",
|
|
@@ -205,6 +226,7 @@ class ContentModule:
|
|
|
205
226
|
module_type: str,
|
|
206
227
|
standard_image_text: StandardImageTextModule = None,
|
|
207
228
|
standard_single_image: StandardSingleImageModule = None,
|
|
229
|
+
standard_single_side_image: StandardSingleSideImageModule = None,
|
|
208
230
|
standard_multiple_image_text: StandardMultipleImageTextModule = None,
|
|
209
231
|
standard_four_image_text: StandardFourImageTextModule = None,
|
|
210
232
|
standard_comparison_table: StandardComparisonTableModule = None,
|
|
@@ -215,6 +237,7 @@ class ContentModule:
|
|
|
215
237
|
self.module_type = module_type
|
|
216
238
|
self.standard_image_text = standard_image_text
|
|
217
239
|
self.standard_single_image = standard_single_image
|
|
240
|
+
self.standard_single_side_image = standard_single_side_image
|
|
218
241
|
self.standard_multiple_image_text = standard_multiple_image_text
|
|
219
242
|
self.standard_four_image_text = standard_four_image_text
|
|
220
243
|
self.standard_comparison_table = standard_comparison_table
|
|
@@ -230,6 +253,8 @@ class ContentModule:
|
|
|
230
253
|
result["standardImageTextOverlay"] = self.standard_image_text_overlay.to_dict()
|
|
231
254
|
elif field_name == "standardSingleImage" and self.standard_single_image:
|
|
232
255
|
result["standardSingleImage"] = self.standard_single_image.to_dict()
|
|
256
|
+
elif field_name == "standardSingleSideImage" and self.standard_single_side_image:
|
|
257
|
+
result["standardSingleSideImage"] = self.standard_single_side_image.to_dict()
|
|
233
258
|
elif field_name == "standardMultipleImageText" and self.standard_multiple_image_text:
|
|
234
259
|
result["standardMultipleImageText"] = self.standard_multiple_image_text.to_dict()
|
|
235
260
|
elif field_name == "standardFourImageText" and self.standard_four_image_text:
|
|
@@ -357,6 +382,26 @@ def build_module_from_json(data: dict) -> ContentModule:
|
|
|
357
382
|
image_caption=TextComponent(data["caption"]) if data.get("caption") else None,
|
|
358
383
|
),
|
|
359
384
|
)
|
|
385
|
+
elif module_type == "STANDARD_SINGLE_SIDE_IMAGE":
|
|
386
|
+
description = None
|
|
387
|
+
if data.get("description"):
|
|
388
|
+
description = ParagraphComponent(text_list=[TextComponent(data["description"])])
|
|
389
|
+
return ContentModule(
|
|
390
|
+
module_type=module_type,
|
|
391
|
+
standard_single_side_image=StandardSingleSideImageModule(
|
|
392
|
+
image=(
|
|
393
|
+
ImageComponent(
|
|
394
|
+
data["imageId"],
|
|
395
|
+
alt_text=data.get("altText"),
|
|
396
|
+
image_crop_specification=data.get("imageCropSpecification"),
|
|
397
|
+
)
|
|
398
|
+
if data.get("imageId")
|
|
399
|
+
else None
|
|
400
|
+
),
|
|
401
|
+
description=description,
|
|
402
|
+
image_position_type=data.get("imagePositionType", "LEFT"),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
360
405
|
elif module_type == "STANDARD_MULTIPLE_IMAGE_TEXT":
|
|
361
406
|
return ContentModule(
|
|
362
407
|
module_type=module_type,
|
|
@@ -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
|
|
@@ -166,6 +166,21 @@ class TestBuildFromJson:
|
|
|
166
166
|
assert result["standardImageTextOverlay"]["block"]["headline"]["value"] == "H"
|
|
167
167
|
assert result["standardImageTextOverlay"]["block"]["body"]["textList"][0]["value"] == "B"
|
|
168
168
|
|
|
169
|
+
def test_build_module_single_side_image(self):
|
|
170
|
+
data = {
|
|
171
|
+
"moduleType": "STANDARD_SINGLE_SIDE_IMAGE",
|
|
172
|
+
"imageId": "img-1",
|
|
173
|
+
"altText": "Side",
|
|
174
|
+
"description": "Desc",
|
|
175
|
+
"imagePositionType": "RIGHT",
|
|
176
|
+
}
|
|
177
|
+
mod = build_module_from_json(data)
|
|
178
|
+
assert mod.module_type == "STANDARD_SINGLE_SIDE_IMAGE"
|
|
179
|
+
result = mod.to_dict()
|
|
180
|
+
assert result["standardSingleSideImage"]["imagePositionType"] == "RIGHT"
|
|
181
|
+
assert result["standardSingleSideImage"]["block"]["image"]["uploadDestinationId"] == "img-1"
|
|
182
|
+
assert result["standardSingleSideImage"]["block"]["description"]["textList"][0]["value"] == "Desc"
|
|
183
|
+
|
|
169
184
|
def test_build_module_comparison_table(self):
|
|
170
185
|
data = {
|
|
171
186
|
"moduleType": "STANDARD_COMPARISON_TABLE",
|
|
@@ -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
|