amazon-sp-cli 0.1.5__tar.gz → 0.2.1__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 (33) hide show
  1. {amazon_sp_cli-0.1.5/amazon_sp_cli.egg-info → amazon_sp_cli-0.2.1}/PKG-INFO +1 -1
  2. amazon_sp_cli-0.2.1/README.md +138 -0
  3. amazon_sp_cli-0.2.1/amazon_sp_cli/cli.py +92 -0
  4. amazon_sp_cli-0.2.1/amazon_sp_cli/client.py +191 -0
  5. amazon_sp_cli-0.2.1/amazon_sp_cli/commands/__init__.py +1 -0
  6. amazon_sp_cli-0.2.1/amazon_sp_cli/commands/a_plus.py +239 -0
  7. amazon_sp_cli-0.2.1/amazon_sp_cli/commands/auth.py +135 -0
  8. amazon_sp_cli-0.2.1/amazon_sp_cli/commands/pricing.py +232 -0
  9. amazon_sp_cli-0.2.1/amazon_sp_cli/main.py +10 -0
  10. amazon_sp_cli-0.2.1/amazon_sp_cli/models/__init__.py +1 -0
  11. amazon_sp_cli-0.2.1/amazon_sp_cli/models/a_plus.py +354 -0
  12. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1/amazon_sp_cli.egg-info}/PKG-INFO +1 -1
  13. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/SOURCES.txt +9 -1
  14. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/setup.py +1 -1
  15. amazon_sp_cli-0.2.1/tests/test_a_plus.py +396 -0
  16. amazon_sp_cli-0.1.5/tests/test_sale_price.py → amazon_sp_cli-0.2.1/tests/test_pricing.py +11 -11
  17. amazon_sp_cli-0.1.5/README.md +0 -100
  18. amazon_sp_cli-0.1.5/amazon_sp_cli/client.py +0 -103
  19. amazon_sp_cli-0.1.5/amazon_sp_cli/main.py +0 -476
  20. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/LICENSE +0 -0
  21. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/MANIFEST.in +0 -0
  22. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli/__init__.py +0 -0
  23. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli/__main__.py +0 -0
  24. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli/auth.py +0 -0
  25. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/dependency_links.txt +0 -0
  26. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/entry_points.txt +0 -0
  27. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/requires.txt +0 -0
  28. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/top_level.txt +0 -0
  29. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/pyproject.toml +0 -0
  30. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/setup.cfg +0 -0
  31. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/tests/__init__.py +0 -0
  32. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/tests/test_auth.py +0 -0
  33. {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/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.5
3
+ Version: 0.2.1
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,138 @@
1
+ # Amazon SP-API CLI
2
+
3
+ Command line interface for Amazon Selling Partner API (SP-API) operations.
4
+
5
+ ## Features
6
+
7
+ - **Pricing Management** — Get/set prices, create discounts
8
+ - **Listing Operations** — View and update listings
9
+ - **Catalog Lookup** — Check competitor ASINs
10
+ - **Inventory Checks** — View FBA inventory levels
11
+ - **Feed Submissions** — Bulk updates via feeds API
12
+ - **A+ Content** — Manage A+ Content documents and image uploads (requires Brand Registry)
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install amazon-sp-cli
18
+ ```
19
+
20
+ ## Setup
21
+
22
+ Create credentials file at `~/.config/amazon-sp-cli/credentials.yml`:
23
+
24
+ ```yaml
25
+ version: '1.0'
26
+
27
+ default:
28
+ refresh_token: "your-refresh-token"
29
+ client_id: "your-client-id"
30
+ client_secret: "your-client-secret"
31
+ aws_access_key_id: "your-aws-access-key"
32
+ aws_secret_access_key: "your-aws-secret-key"
33
+ seller_id: "your-seller-id"
34
+ marketplace_id: "ATVPDKIKX0DER" # US marketplace
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### Pricing
40
+
41
+ ```bash
42
+ # Get current price
43
+ amz-sp get-price PAW2603190101-BLU
44
+
45
+ # Set new price
46
+ amz-sp set-price PAW2603190101-BLU 11.99
47
+
48
+ # Create sale price feed JSON
49
+ amz-sp sale-price PAW2603190101-BLU 23
50
+
51
+ # Create discount feed JSON (legacy)
52
+ amz-sp create-discount PAW2603190101-BLU 23
53
+ ```
54
+
55
+ ### Catalog
56
+
57
+ ```bash
58
+ # Check competitor
59
+ amz-sp check-competitors B0GW72JGWK
60
+ ```
61
+
62
+ ### A+ Content
63
+
64
+ Requires Brand Registry.
65
+
66
+ ```bash
67
+ # Upload an image (returns uploadDestinationId)
68
+ amz-sp a-plus upload-image banner.jpg
69
+
70
+ # Create content from JSON
71
+ amz-sp a-plus create my-content --data content.json
72
+
73
+ # Validate without creating
74
+ amz-sp a-plus validate my-content --data content.json
75
+
76
+ # Get content details (includes approval status)
77
+ amz-sp a-plus get my-content
78
+
79
+ # List all content documents
80
+ amz-sp a-plus list
81
+
82
+ # Update content
83
+ amz-sp a-plus update my-content --data content.json
84
+
85
+ # Delete content
86
+ amz-sp a-plus delete my-content
87
+
88
+ # Associate ASINs
89
+ amz-sp a-plus asin add my-content B123456789 B987654321
90
+
91
+ # List associated ASINs
92
+ amz-sp a-plus asin list my-content
93
+
94
+ # Remove ASIN associations
95
+ amz-sp a-plus asin remove my-content B123456789
96
+ ```
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ # Clone repo
102
+ git clone https://github.com/stellaraether/amazon-sp-cli.git
103
+ cd amazon-sp-cli
104
+
105
+ # One-shot setup (creates venv, installs deps, sets up pre-commit)
106
+ ./setup.sh
107
+ source .venv/bin/activate
108
+
109
+ # Or manually
110
+ python3 -m venv .venv
111
+ source .venv/bin/activate
112
+ pip install -r requirements-dev.txt
113
+ pre-commit install
114
+
115
+ # Run locally
116
+ python3 -m amazon_sp_cli get-price PAW2603190101-BLU
117
+ ```
118
+
119
+ ## Releasing
120
+
121
+ This repository uses automated releases. To publish a new version:
122
+
123
+ 1. Open a **standalone PR** that only bumps `version` in `setup.py`.
124
+ 2. The PR must not include code, test, or documentation changes — a CI check enforces this.
125
+ 3. Once the PR is merged to `main`, the `Auto Release` workflow detects the new version, creates a tag (e.g. `v0.2.1`), and pushes it.
126
+ 4. The tag push triggers the `Publish to PyPI` workflow, which runs the full test suite and publishes the package.
127
+
128
+ Do not create tags manually.
129
+
130
+ ## Requirements
131
+
132
+ - Python 3.8+
133
+ - Amazon SP-API credentials
134
+ - AWS IAM user with SP-API access
135
+
136
+ ## License
137
+
138
+ MIT
@@ -0,0 +1,92 @@
1
+ """Core CLI infrastructure and shared utilities."""
2
+
3
+ import functools
4
+ import os
5
+ import sys
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from .auth import SPAPIAuth
12
+ from .client import SPAPIClient
13
+
14
+ DEFAULT_CREDENTIALS_PATH = os.path.expanduser("~/.config/amazon-sp-cli/credentials.yml")
15
+
16
+
17
+ def _check_path():
18
+ """Check if the CLI is accessible in PATH and warn once per day."""
19
+ import shutil
20
+
21
+ if shutil.which("amz-sp"):
22
+ return
23
+
24
+ # Only warn once per day
25
+ flag_file = Path.home() / ".config" / "amazon-sp-cli" / ".path-warned"
26
+ flag_file.parent.mkdir(parents=True, exist_ok=True)
27
+
28
+ now = datetime.now(timezone.utc)
29
+ today = now.strftime("%Y-%m-%d")
30
+
31
+ if flag_file.exists():
32
+ last_warned = flag_file.read_text().strip()
33
+ if last_warned == today:
34
+ return
35
+
36
+ flag_file.write_text(today)
37
+
38
+ print(
39
+ "\n⚠️ Note: 'amz-sp' is not in your PATH.",
40
+ file=sys.stderr,
41
+ )
42
+ print(
43
+ " You can still use: python3 -m amazon_sp_cli",
44
+ file=sys.stderr,
45
+ )
46
+ print(
47
+ " To add to PATH, add this to your shell config:",
48
+ file=sys.stderr,
49
+ )
50
+ print(
51
+ f' export PATH="{sys.prefix}/bin:$PATH"',
52
+ file=sys.stderr,
53
+ )
54
+ print("", file=sys.stderr)
55
+
56
+
57
+ def _ensure_auth_client(ctx):
58
+ """Lazily create auth and client if not already present."""
59
+ if "client" not in ctx.obj:
60
+ auth = SPAPIAuth(ctx.obj.get("credentials_path"))
61
+ if auth.credentials is None:
62
+ click.echo("Error: No credentials found. Run 'amz-sp auth setup' first.", err=True)
63
+ raise click.Abort()
64
+ ctx.obj["auth"] = auth
65
+ ctx.obj["client"] = SPAPIClient(auth)
66
+ return ctx.obj["auth"], ctx.obj["client"]
67
+
68
+
69
+ def handle_errors(f):
70
+ """Decorator to catch exceptions and emit consistent error messages."""
71
+
72
+ @functools.wraps(f)
73
+ def wrapper(*args, **kwargs):
74
+ try:
75
+ return f(*args, **kwargs)
76
+ except click.ClickException:
77
+ raise
78
+ except Exception as e:
79
+ click.echo(f"Error: {e}", err=True)
80
+ raise click.Abort()
81
+
82
+ return wrapper
83
+
84
+
85
+ @click.group()
86
+ @click.option("--credentials", "-c", help="Path to credentials YAML file")
87
+ @click.pass_context
88
+ def cli(ctx, credentials):
89
+ """Amazon SP-API CLI - Manage listings, pricing, inventory, and more."""
90
+ _check_path()
91
+ ctx.ensure_object(dict)
92
+ ctx.obj["credentials_path"] = credentials
@@ -0,0 +1,191 @@
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
+
11
+
12
+ class SPAPIClient:
13
+ """Client for making signed requests to Amazon SP-API."""
14
+
15
+ HOST = "sellingpartnerapi-na.amazon.com"
16
+ REGION = "us-east-1"
17
+ SERVICE = "execute-api"
18
+
19
+ def __init__(self, auth, aws_access_key: str = None, aws_secret_key: str = None):
20
+ self.auth = auth
21
+ self.credentials = auth.credentials
22
+ self.aws_access_key = aws_access_key or self.credentials.get("aws_access_key_id")
23
+ self.aws_secret_key = aws_secret_key or self.credentials.get("aws_secret_access_key")
24
+ self.marketplace_id = self.credentials.get("marketplace_id", "ATVPDKIKX0DER")
25
+ self.seller_id = self.credentials.get("seller_id")
26
+
27
+ def _sign_request(self, method: str, path: str, data: dict = None) -> dict:
28
+ """Create signed request headers."""
29
+ access_token = self.auth.get_access_token()
30
+
31
+ headers = {
32
+ "x-amz-access-token": access_token,
33
+ "accept": "application/json",
34
+ }
35
+
36
+ if data is not None:
37
+ headers["content-type"] = "application/json"
38
+
39
+ # Create the request
40
+ url = f"https://{self.HOST}{path}"
41
+ body = json.dumps(data) if data is not None else ""
42
+
43
+ # Create AWS request for signing
44
+ request = AWSRequest(method=method, url=url, headers=headers, data=body)
45
+
46
+ # Sign with SigV4
47
+ credentials = Credentials(self.aws_access_key, self.aws_secret_key)
48
+ signer = SigV4Auth(credentials, self.SERVICE, self.REGION)
49
+ signer.add_auth(request)
50
+
51
+ return dict(request.headers), request.url, body
52
+
53
+ def request(self, method: str, path: str, data: dict = None) -> dict:
54
+ """Make a signed request to SP-API."""
55
+ headers, url, body = self._sign_request(method, path, data)
56
+
57
+ response = requests.request(method, url, headers=headers, data=body)
58
+ response.raise_for_status()
59
+
60
+ return response.json() if response.text else {}
61
+
62
+ def get_listing(self, sku: str) -> dict:
63
+ """Get listing information for a SKU."""
64
+ path = f"/listings/2021-08-01/items/{self.seller_id}/{sku}"
65
+ params = {
66
+ "marketplaceIds": self.marketplace_id,
67
+ "includedData": "summaries,attributes",
68
+ }
69
+ path += "?" + urlencode(params)
70
+ return self.request("GET", path)
71
+
72
+ def update_price(self, sku: str, price: float, mode: str = "VALIDATION_PREVIEW") -> dict:
73
+ """Update listing price."""
74
+ path = f"/listings/2021-08-01/items/{self.seller_id}/{sku}"
75
+ params = {
76
+ "marketplaceIds": self.marketplace_id,
77
+ }
78
+ if mode:
79
+ params["mode"] = mode
80
+ path += "?" + urlencode(params)
81
+
82
+ data = {
83
+ "productType": "PET_TOY",
84
+ "requirements": "LISTING_OFFER_ONLY",
85
+ "attributes": {
86
+ "condition_type": [{"value": "new_new"}],
87
+ "item_name": [{"value": "Placeholder"}],
88
+ "list_price": [{"currency": "USD", "value": price}],
89
+ "purchasable_price": [{"currency": "USD", "value": price}],
90
+ },
91
+ }
92
+
93
+ return self.request("PUT", path, data)
94
+
95
+ def get_catalog_item(self, asin: str) -> dict:
96
+ """Get catalog item details."""
97
+ path = f"/catalog/2022-04-01/items/{asin}"
98
+ params = {
99
+ "marketplaceIds": self.marketplace_id,
100
+ "includedData": "attributes,salesRanks",
101
+ }
102
+ path += "?" + urlencode(params)
103
+ return self.request("GET", path)
104
+
105
+ # --- A+ Content API ---
106
+
107
+ def _add_marketplace_param(self, path: str) -> str:
108
+ """Append marketplaceId query param to A+ Content paths."""
109
+ separator = "&" if "?" in path else "?"
110
+ return f"{path}{separator}marketplaceId={self.marketplace_id}"
111
+
112
+ def create_a_plus_content(self, content_data: dict) -> dict:
113
+ """Create A+ Content document."""
114
+ path = self._add_marketplace_param("/aplus/2020-11-01/contentDocuments")
115
+ data = {"contentDocument": content_data}
116
+ return self.request("POST", path, data)
117
+
118
+ def validate_a_plus_content(self, content_data: dict) -> dict:
119
+ """Validate A+ Content without creating."""
120
+ path = "/aplus/2020-11-01/contentDocuments"
121
+ params = {"mode": "VALIDATION_PREVIEW", "marketplaceId": self.marketplace_id}
122
+ path += "?" + urlencode(params)
123
+ data = {"contentDocument": content_data}
124
+ return self.request("POST", path, data)
125
+
126
+ def update_a_plus_content(self, content_name: str, content_data: dict) -> dict:
127
+ """Update existing A+ Content document."""
128
+ path = self._add_marketplace_param(f"/aplus/2020-11-01/contentDocuments/{content_name}")
129
+ data = {"contentDocument": content_data}
130
+ return self.request("POST", path, data)
131
+
132
+ def get_a_plus_content(self, content_name: str) -> dict:
133
+ """Get A+ Content document by name."""
134
+ path = f"/aplus/2020-11-01/contentDocuments/{content_name}"
135
+ params = {"marketplaceId": self.marketplace_id, "includedDataSet": "CONTENTS"}
136
+ path += "?" + urlencode(params)
137
+ return self.request("GET", path)
138
+
139
+ def list_a_plus_content(self, **filters) -> dict:
140
+ """List A+ Content documents."""
141
+ path = "/aplus/2020-11-01/contentDocuments"
142
+ params = {"marketplaceId": self.marketplace_id}
143
+ params.update(filters)
144
+ path += "?" + urlencode(params)
145
+ return self.request("GET", path)
146
+
147
+ def suspend_a_plus_content(self, content_reference_key: str) -> dict:
148
+ """Suspend A+ Content document (API does not support delete)."""
149
+ path = f"/aplus/2020-11-01/contentDocuments/{content_reference_key}/suspendSubmissions"
150
+ params = {"marketplaceId": self.marketplace_id}
151
+ path += "?" + urlencode(params)
152
+ return self.request("POST", path)
153
+
154
+ def get_a_plus_content_asin_relations(self, content_name: str) -> dict:
155
+ """Get ASIN relations for a content document."""
156
+ path = self._add_marketplace_param(f"/aplus/2020-11-01/contentDocuments/{content_name}/asins")
157
+ return self.request("GET", path)
158
+
159
+ def post_a_plus_content_asin_relations(self, content_name: str, asin_set: list) -> dict:
160
+ """Associate ASINs with A+ Content document."""
161
+ path = self._add_marketplace_param("/aplus/2020-11-01/contentAsinRelations")
162
+ data = {"contentDocumentName": content_name, "asinSet": asin_set}
163
+ return self.request("POST", path, data)
164
+
165
+ def delete_a_plus_content_asin_relations(self, content_name: str, asin_set: list) -> dict:
166
+ """Remove ASIN associations from A+ Content document."""
167
+ path = "/aplus/2020-11-01/contentAsinRelations"
168
+ params = {"contentDocumentName": content_name, "marketplaceId": self.marketplace_id}
169
+ path += "?" + urlencode(params)
170
+ data = {"asinSet": asin_set}
171
+ return self.request("DELETE", path, data)
172
+
173
+ def create_upload_destination(
174
+ self,
175
+ marketplace_id: str,
176
+ content_md5: str,
177
+ content_type: str,
178
+ file_name: str = None,
179
+ resource: str = None,
180
+ ) -> dict:
181
+ """Create an upload destination for a file."""
182
+ path = f"/uploads/2020-11-01/uploadDestinations/{resource}"
183
+ params = {
184
+ "marketplaceIds": marketplace_id,
185
+ "contentMD5": content_md5,
186
+ "contentType": content_type,
187
+ }
188
+ if file_name:
189
+ params["fileName"] = file_name
190
+ path += "?" + urlencode(params)
191
+ return self.request("POST", path)
@@ -0,0 +1 @@
1
+ """CLI command modules."""
@@ -0,0 +1,239 @@
1
+ """A+ Content CLI commands."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import urllib.parse
8
+
9
+ import click
10
+ import requests
11
+
12
+ from ..cli import handle_errors
13
+ from ..models.a_plus import build_content_from_json
14
+
15
+
16
+ def register_a_plus_commands(cli_group, ensure_auth_client):
17
+ """Register A+ Content CLI commands."""
18
+
19
+ @cli_group.group("a-plus")
20
+ def a_plus():
21
+ """A+ Content management (requires Brand Registry)."""
22
+ pass
23
+
24
+ @a_plus.command("create")
25
+ @click.argument("content-name")
26
+ @click.option("--data", "-d", type=click.Path(exists=True), required=True, help="JSON file with content data")
27
+ @click.option("--dry-run", is_flag=True, help="Validate without creating")
28
+ @click.pass_context
29
+ @handle_errors
30
+ def create_content(ctx, content_name, data, dry_run):
31
+ """Create A+ Content document from JSON file."""
32
+ _, client = ensure_auth_client(ctx)
33
+
34
+ with open(data, "r") as f:
35
+ content_data = json.load(f)
36
+
37
+ content = build_content_from_json(content_name, content_data)
38
+
39
+ issues = content.validate()
40
+ if issues:
41
+ click.echo("Validation issues:", err=True)
42
+ for issue in issues:
43
+ click.echo(f" - {issue}", err=True)
44
+ raise click.Abort()
45
+
46
+ if dry_run:
47
+ click.echo("Content is valid (dry run)")
48
+ click.echo(json.dumps(content.to_dict(), indent=2))
49
+ return
50
+
51
+ response = client.create_a_plus_content(content.to_dict())
52
+ click.echo(f"A+ Content created: {content_name}")
53
+ click.echo(f"Modules: {len(content.content_module_list)}")
54
+ if response:
55
+ click.echo(json.dumps(response, indent=2))
56
+
57
+ @a_plus.command("validate")
58
+ @click.argument("content-name")
59
+ @click.option("--data", "-d", type=click.Path(exists=True), required=True, help="JSON file with content data")
60
+ @click.pass_context
61
+ @handle_errors
62
+ def validate_content(ctx, content_name, data):
63
+ """Validate A+ Content without creating."""
64
+ _, client = ensure_auth_client(ctx)
65
+
66
+ with open(data, "r") as f:
67
+ content_data = json.load(f)
68
+
69
+ content = build_content_from_json(content_name, content_data)
70
+
71
+ issues = content.validate()
72
+ if issues:
73
+ click.echo("Validation issues:", err=True)
74
+ for issue in issues:
75
+ click.echo(f" - {issue}", err=True)
76
+ raise click.Abort()
77
+
78
+ response = client.validate_a_plus_content(content.to_dict())
79
+ click.echo("API validation passed")
80
+ if response:
81
+ click.echo(json.dumps(response, indent=2))
82
+
83
+ @a_plus.command("get")
84
+ @click.argument("content-reference-key")
85
+ @click.pass_context
86
+ @handle_errors
87
+ def get_content(ctx, content_reference_key):
88
+ """Get A+ Content document by contentReferenceKey."""
89
+ _, client = ensure_auth_client(ctx)
90
+ response = client.get_a_plus_content(content_reference_key)
91
+ click.echo(json.dumps(response, indent=2))
92
+
93
+ @a_plus.command("list")
94
+ @click.pass_context
95
+ @handle_errors
96
+ def list_content(ctx):
97
+ """List A+ Content documents."""
98
+ _, client = ensure_auth_client(ctx)
99
+ response = client.list_a_plus_content(marketplaceId=client.marketplace_id)
100
+ documents = response.get("contentDocumentList", [])
101
+ click.echo(f"\nA+ Content Documents ({len(documents)} found):\n")
102
+ for doc in documents:
103
+ click.echo(f" Name: {doc.get('name')}")
104
+ click.echo(f" Status: {doc.get('status', 'N/A')}")
105
+ click.echo(f" Locale: {doc.get('locale', 'N/A')}")
106
+ asins = doc.get("asinSet", [])
107
+ if asins:
108
+ click.echo(f" ASINs: {', '.join(asins)}")
109
+ click.echo()
110
+
111
+ @a_plus.command("update")
112
+ @click.argument("content-name")
113
+ @click.option("--data", "-d", type=click.Path(exists=True), required=True, help="JSON file with updated content")
114
+ @click.pass_context
115
+ @handle_errors
116
+ def update_content(ctx, content_name, data):
117
+ """Update existing A+ Content document."""
118
+ _, client = ensure_auth_client(ctx)
119
+
120
+ with open(data, "r") as f:
121
+ content_data = json.load(f)
122
+
123
+ content = build_content_from_json(content_name, content_data)
124
+
125
+ issues = content.validate()
126
+ if issues:
127
+ click.echo("Validation issues:", err=True)
128
+ for issue in issues:
129
+ click.echo(f" - {issue}", err=True)
130
+ raise click.Abort()
131
+
132
+ response = client.update_a_plus_content(content_name, content.to_dict())
133
+ click.echo(f"A+ Content updated: {content_name}")
134
+ if response:
135
+ click.echo(json.dumps(response, indent=2))
136
+
137
+ @a_plus.command("suspend")
138
+ @click.argument("content-reference-key")
139
+ @click.confirmation_option(prompt="Suspend this A+ Content?")
140
+ @click.pass_context
141
+ @handle_errors
142
+ def suspend_content(ctx, content_reference_key):
143
+ """Suspend A+ Content document (API does not support delete)."""
144
+ _, client = ensure_auth_client(ctx)
145
+ client.suspend_a_plus_content(content_reference_key)
146
+ click.echo(f"A+ Content suspended: {content_reference_key}")
147
+
148
+ @a_plus.command("upload-image")
149
+ @click.argument("file-path", type=click.Path(exists=True))
150
+ @click.option("--content-type", default="image/jpeg", help="MIME type of the image")
151
+ @click.option(
152
+ "--resource", default="aplus/2020-11-01/contentDocuments", help="Resource type for upload destination"
153
+ )
154
+ @click.pass_context
155
+ @handle_errors
156
+ def upload_image(ctx, file_path, content_type, resource):
157
+ """Upload an image and return an uploadDestinationId for A+ Content."""
158
+ _, client = ensure_auth_client(ctx)
159
+
160
+ with open(file_path, "rb") as f:
161
+ file_data = f.read()
162
+
163
+ md5_hash = hashlib.md5(file_data).digest()
164
+ content_md5 = base64.b64encode(md5_hash).decode("ascii")
165
+ file_name = os.path.basename(file_path)
166
+
167
+ response = client.create_upload_destination(
168
+ marketplace_id=client.marketplace_id,
169
+ content_md5=content_md5,
170
+ content_type=content_type,
171
+ file_name=file_name,
172
+ resource=resource,
173
+ )
174
+
175
+ payload = response.get("payload", response)
176
+ upload_destination_id = payload.get("uploadDestinationId")
177
+ upload_url = payload.get("url")
178
+
179
+ if not upload_url:
180
+ click.echo("Error: No upload URL returned", err=True)
181
+ raise click.Abort()
182
+
183
+ parsed = urllib.parse.urlparse(upload_url)
184
+ base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
185
+ form_fields = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
186
+
187
+ post_response = requests.post(
188
+ base_url,
189
+ data=form_fields,
190
+ files={"File": (file_name, file_data, content_type)},
191
+ )
192
+ post_response.raise_for_status()
193
+
194
+ click.echo(f"Upload successful: {file_name}")
195
+ click.echo(f"uploadDestinationId: {upload_destination_id}")
196
+
197
+ @a_plus.group("asin")
198
+ def asin():
199
+ """ASIN association commands."""
200
+ pass
201
+
202
+ @asin.command("add")
203
+ @click.argument("content-name")
204
+ @click.argument("asins", nargs=-1, required=True)
205
+ @click.pass_context
206
+ @handle_errors
207
+ def add_asins(ctx, content_name, asins):
208
+ """Associate ASINs with A+ Content."""
209
+ _, client = ensure_auth_client(ctx)
210
+ response = client.post_a_plus_content_asin_relations(content_name, list(asins))
211
+ click.echo(f"Associated {len(asins)} ASIN(s) with {content_name}")
212
+ if response:
213
+ click.echo(json.dumps(response, indent=2))
214
+
215
+ @asin.command("remove")
216
+ @click.argument("content-name")
217
+ @click.argument("asins", nargs=-1, required=True)
218
+ @click.pass_context
219
+ @handle_errors
220
+ def remove_asins(ctx, content_name, asins):
221
+ """Remove ASIN associations from A+ Content."""
222
+ _, client = ensure_auth_client(ctx)
223
+ response = client.delete_a_plus_content_asin_relations(content_name, list(asins))
224
+ click.echo(f"Removed {len(asins)} ASIN(s) from {content_name}")
225
+ if response:
226
+ click.echo(json.dumps(response, indent=2))
227
+
228
+ @asin.command("list")
229
+ @click.argument("content-name")
230
+ @click.pass_context
231
+ @handle_errors
232
+ def list_asins(ctx, content_name):
233
+ """List ASINs associated with A+ Content."""
234
+ _, client = ensure_auth_client(ctx)
235
+ response = client.get_a_plus_content_asin_relations(content_name)
236
+ asins = response.get("asinSet", [])
237
+ click.echo(f"\nASINs for '{content_name}' ({len(asins)} found):\n")
238
+ for asin in asins:
239
+ click.echo(f" {asin}")