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.
- {amazon_sp_cli-0.1.5/amazon_sp_cli.egg-info → amazon_sp_cli-0.2.1}/PKG-INFO +1 -1
- amazon_sp_cli-0.2.1/README.md +138 -0
- amazon_sp_cli-0.2.1/amazon_sp_cli/cli.py +92 -0
- amazon_sp_cli-0.2.1/amazon_sp_cli/client.py +191 -0
- amazon_sp_cli-0.2.1/amazon_sp_cli/commands/__init__.py +1 -0
- amazon_sp_cli-0.2.1/amazon_sp_cli/commands/a_plus.py +239 -0
- amazon_sp_cli-0.2.1/amazon_sp_cli/commands/auth.py +135 -0
- amazon_sp_cli-0.2.1/amazon_sp_cli/commands/pricing.py +232 -0
- amazon_sp_cli-0.2.1/amazon_sp_cli/main.py +10 -0
- amazon_sp_cli-0.2.1/amazon_sp_cli/models/__init__.py +1 -0
- amazon_sp_cli-0.2.1/amazon_sp_cli/models/a_plus.py +354 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1/amazon_sp_cli.egg-info}/PKG-INFO +1 -1
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/SOURCES.txt +9 -1
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/setup.py +1 -1
- amazon_sp_cli-0.2.1/tests/test_a_plus.py +396 -0
- amazon_sp_cli-0.1.5/tests/test_sale_price.py → amazon_sp_cli-0.2.1/tests/test_pricing.py +11 -11
- amazon_sp_cli-0.1.5/README.md +0 -100
- amazon_sp_cli-0.1.5/amazon_sp_cli/client.py +0 -103
- amazon_sp_cli-0.1.5/amazon_sp_cli/main.py +0 -476
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/LICENSE +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/MANIFEST.in +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli/__init__.py +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli/__main__.py +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli/auth.py +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/dependency_links.txt +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/entry_points.txt +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/requires.txt +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/amazon_sp_cli.egg-info/top_level.txt +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/pyproject.toml +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/setup.cfg +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/tests/__init__.py +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/tests/test_auth.py +0 -0
- {amazon_sp_cli-0.1.5 → amazon_sp_cli-0.2.1}/tests/test_client.py +0 -0
|
@@ -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}")
|