amazon-sp-cli 0.1.4__tar.gz → 0.2.0__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.4/amazon_sp_cli.egg-info → amazon_sp_cli-0.2.0}/PKG-INFO +1 -1
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/README.md +37 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli/auth.py +5 -1
- amazon_sp_cli-0.2.0/amazon_sp_cli/cli.py +92 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli/client.py +79 -0
- amazon_sp_cli-0.2.0/amazon_sp_cli/commands/__init__.py +1 -0
- amazon_sp_cli-0.2.0/amazon_sp_cli/commands/a_plus.py +233 -0
- amazon_sp_cli-0.2.0/amazon_sp_cli/commands/auth.py +135 -0
- amazon_sp_cli-0.2.0/amazon_sp_cli/commands/pricing.py +232 -0
- amazon_sp_cli-0.2.0/amazon_sp_cli/main.py +10 -0
- amazon_sp_cli-0.2.0/amazon_sp_cli/models/__init__.py +1 -0
- amazon_sp_cli-0.2.0/amazon_sp_cli/models/a_plus.py +338 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0/amazon_sp_cli.egg-info}/PKG-INFO +1 -1
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/SOURCES.txt +9 -1
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/setup.py +1 -1
- amazon_sp_cli-0.2.0/tests/test_a_plus.py +379 -0
- amazon_sp_cli-0.1.4/tests/test_sale_price.py → amazon_sp_cli-0.2.0/tests/test_pricing.py +11 -11
- amazon_sp_cli-0.1.4/amazon_sp_cli/main.py +0 -464
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/LICENSE +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/MANIFEST.in +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli/__init__.py +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli/__main__.py +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/dependency_links.txt +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/entry_points.txt +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/requires.txt +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/top_level.txt +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/pyproject.toml +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/setup.cfg +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/tests/__init__.py +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/tests/test_auth.py +0 -0
- {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/tests/test_client.py +0 -0
|
@@ -9,6 +9,7 @@ Command line interface for Amazon Selling Partner API (SP-API) operations.
|
|
|
9
9
|
- **Catalog Lookup** — Check competitor ASINs
|
|
10
10
|
- **Inventory Checks** — View FBA inventory levels
|
|
11
11
|
- **Feed Submissions** — Bulk updates via feeds API
|
|
12
|
+
- **A+ Content** — Manage A+ Content documents and image uploads (requires Brand Registry)
|
|
12
13
|
|
|
13
14
|
## Installation
|
|
14
15
|
|
|
@@ -75,6 +76,42 @@ amz-sp inventory list
|
|
|
75
76
|
amz-sp inventory get PAW2603190101-BLU
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
### A+ Content
|
|
80
|
+
|
|
81
|
+
Requires Brand Registry.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Upload an image (returns uploadDestinationId)
|
|
85
|
+
amz-sp a-plus upload-image banner.jpg
|
|
86
|
+
|
|
87
|
+
# Create content from JSON
|
|
88
|
+
amz-sp a-plus create my-content --data content.json
|
|
89
|
+
|
|
90
|
+
# Validate without creating
|
|
91
|
+
amz-sp a-plus validate my-content --data content.json
|
|
92
|
+
|
|
93
|
+
# Get content details (includes approval status)
|
|
94
|
+
amz-sp a-plus get my-content
|
|
95
|
+
|
|
96
|
+
# List all content documents
|
|
97
|
+
amz-sp a-plus list
|
|
98
|
+
|
|
99
|
+
# Update content
|
|
100
|
+
amz-sp a-plus update my-content --data content.json
|
|
101
|
+
|
|
102
|
+
# Delete content
|
|
103
|
+
amz-sp a-plus delete my-content
|
|
104
|
+
|
|
105
|
+
# Associate ASINs
|
|
106
|
+
amz-sp a-plus asin add my-content B123456789 B987654321
|
|
107
|
+
|
|
108
|
+
# List associated ASINs
|
|
109
|
+
amz-sp a-plus asin list my-content
|
|
110
|
+
|
|
111
|
+
# Remove ASIN associations
|
|
112
|
+
amz-sp a-plus asin remove my-content B123456789
|
|
113
|
+
```
|
|
114
|
+
|
|
78
115
|
## Development
|
|
79
116
|
|
|
80
117
|
```bash
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import time
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
7
8
|
|
|
8
9
|
import requests
|
|
9
10
|
import yaml
|
|
@@ -20,11 +21,14 @@ class SPAPIAuth:
|
|
|
20
21
|
self.credentials = self._load_credentials(credentials_path)
|
|
21
22
|
self._ensure_cache_dir()
|
|
22
23
|
|
|
23
|
-
def _load_credentials(self, path: str = None) -> dict:
|
|
24
|
+
def _load_credentials(self, path: str = None) -> Optional[dict]:
|
|
24
25
|
"""Load credentials from YAML file."""
|
|
25
26
|
if path is None:
|
|
26
27
|
path = Path.home() / ".config" / "amazon-sp-cli" / "credentials.yml"
|
|
27
28
|
|
|
29
|
+
if not Path(path).exists():
|
|
30
|
+
return None
|
|
31
|
+
|
|
28
32
|
with open(path, "r") as f:
|
|
29
33
|
config = yaml.safe_load(f)
|
|
30
34
|
|
|
@@ -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
|
|
@@ -101,3 +101,82 @@ class SPAPIClient:
|
|
|
101
101
|
}
|
|
102
102
|
path += "?" + urlencode(params)
|
|
103
103
|
return self.request("GET", path)
|
|
104
|
+
|
|
105
|
+
# --- A+ Content API ---
|
|
106
|
+
|
|
107
|
+
def create_a_plus_content(self, content_data: dict) -> dict:
|
|
108
|
+
"""Create A+ Content document."""
|
|
109
|
+
path = "/aplus/2020-11-01/contentDocuments"
|
|
110
|
+
data = {"contentDocument": content_data}
|
|
111
|
+
return self.request("POST", path, data)
|
|
112
|
+
|
|
113
|
+
def validate_a_plus_content(self, content_data: dict) -> dict:
|
|
114
|
+
"""Validate A+ Content without creating."""
|
|
115
|
+
path = "/aplus/2020-11-01/contentDocuments"
|
|
116
|
+
params = {"mode": "VALIDATION_PREVIEW"}
|
|
117
|
+
path += "?" + urlencode(params)
|
|
118
|
+
data = {"contentDocument": content_data}
|
|
119
|
+
return self.request("POST", path, data)
|
|
120
|
+
|
|
121
|
+
def update_a_plus_content(self, content_name: str, content_data: dict) -> dict:
|
|
122
|
+
"""Update existing A+ Content document."""
|
|
123
|
+
path = f"/aplus/2020-11-01/contentDocuments/{content_name}"
|
|
124
|
+
data = {"contentDocument": content_data}
|
|
125
|
+
return self.request("POST", path, data)
|
|
126
|
+
|
|
127
|
+
def get_a_plus_content(self, content_name: str) -> dict:
|
|
128
|
+
"""Get A+ Content document by name."""
|
|
129
|
+
path = f"/aplus/2020-11-01/contentDocuments/{content_name}"
|
|
130
|
+
return self.request("GET", path)
|
|
131
|
+
|
|
132
|
+
def list_a_plus_content(self, **filters) -> dict:
|
|
133
|
+
"""List A+ Content documents."""
|
|
134
|
+
path = "/aplus/2020-11-01/contentDocuments"
|
|
135
|
+
if filters:
|
|
136
|
+
path += "?" + urlencode(filters)
|
|
137
|
+
return self.request("GET", path)
|
|
138
|
+
|
|
139
|
+
def delete_a_plus_content(self, content_name: str) -> dict:
|
|
140
|
+
"""Delete A+ Content document."""
|
|
141
|
+
path = f"/aplus/2020-11-01/contentDocuments/{content_name}"
|
|
142
|
+
return self.request("DELETE", path)
|
|
143
|
+
|
|
144
|
+
def get_a_plus_content_asin_relations(self, content_name: str) -> dict:
|
|
145
|
+
"""Get ASIN relations for a content document."""
|
|
146
|
+
path = f"/aplus/2020-11-01/contentDocuments/{content_name}/asins"
|
|
147
|
+
return self.request("GET", path)
|
|
148
|
+
|
|
149
|
+
def post_a_plus_content_asin_relations(self, content_name: str, asin_set: list) -> dict:
|
|
150
|
+
"""Associate ASINs with A+ Content document."""
|
|
151
|
+
path = "/aplus/2020-11-01/contentAsinRelations"
|
|
152
|
+
data = {"contentDocumentName": content_name, "asinSet": asin_set}
|
|
153
|
+
return self.request("POST", path, data)
|
|
154
|
+
|
|
155
|
+
def delete_a_plus_content_asin_relations(self, content_name: str, asin_set: list) -> dict:
|
|
156
|
+
"""Remove ASIN associations from A+ Content document."""
|
|
157
|
+
path = "/aplus/2020-11-01/contentAsinRelations"
|
|
158
|
+
params = {"contentDocumentName": content_name}
|
|
159
|
+
path += "?" + urlencode(params)
|
|
160
|
+
data = {"asinSet": asin_set}
|
|
161
|
+
return self.request("DELETE", path, data)
|
|
162
|
+
|
|
163
|
+
def create_upload_destination(
|
|
164
|
+
self,
|
|
165
|
+
marketplace_id: str,
|
|
166
|
+
content_md5: str,
|
|
167
|
+
content_type: str,
|
|
168
|
+
file_name: str = None,
|
|
169
|
+
resource: str = None,
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""Create an upload destination for a file."""
|
|
172
|
+
path = f"/uploads/2020-11-01/uploadDestinations/{marketplace_id}"
|
|
173
|
+
params = {
|
|
174
|
+
"contentMD5": content_md5,
|
|
175
|
+
"contentType": content_type,
|
|
176
|
+
}
|
|
177
|
+
if file_name:
|
|
178
|
+
params["fileName"] = file_name
|
|
179
|
+
if resource:
|
|
180
|
+
params["resource"] = resource
|
|
181
|
+
path += "?" + urlencode(params)
|
|
182
|
+
return self.request("POST", path)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""A+ Content CLI commands."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from ..cli import handle_errors
|
|
12
|
+
from ..models.a_plus import build_content_from_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_a_plus_commands(cli_group, ensure_auth_client):
|
|
16
|
+
"""Register A+ Content CLI commands."""
|
|
17
|
+
|
|
18
|
+
@cli_group.group("a-plus")
|
|
19
|
+
def a_plus():
|
|
20
|
+
"""A+ Content management (requires Brand Registry)."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@a_plus.command("create")
|
|
24
|
+
@click.argument("content-name")
|
|
25
|
+
@click.option("--data", "-d", type=click.Path(exists=True), required=True, help="JSON file with content data")
|
|
26
|
+
@click.option("--dry-run", is_flag=True, help="Validate without creating")
|
|
27
|
+
@click.pass_context
|
|
28
|
+
@handle_errors
|
|
29
|
+
def create_content(ctx, content_name, data, dry_run):
|
|
30
|
+
"""Create A+ Content document from JSON file."""
|
|
31
|
+
_, client = ensure_auth_client(ctx)
|
|
32
|
+
|
|
33
|
+
with open(data, "r") as f:
|
|
34
|
+
content_data = json.load(f)
|
|
35
|
+
|
|
36
|
+
content = build_content_from_json(content_name, content_data)
|
|
37
|
+
|
|
38
|
+
issues = content.validate()
|
|
39
|
+
if issues:
|
|
40
|
+
click.echo("Validation issues:", err=True)
|
|
41
|
+
for issue in issues:
|
|
42
|
+
click.echo(f" - {issue}", err=True)
|
|
43
|
+
raise click.Abort()
|
|
44
|
+
|
|
45
|
+
if dry_run:
|
|
46
|
+
click.echo("Content is valid (dry run)")
|
|
47
|
+
click.echo(json.dumps(content.to_dict(), indent=2))
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
response = client.create_a_plus_content(content.to_dict())
|
|
51
|
+
click.echo(f"A+ Content created: {content_name}")
|
|
52
|
+
click.echo(f"Modules: {len(content.content_module_list)}")
|
|
53
|
+
if response:
|
|
54
|
+
click.echo(json.dumps(response, indent=2))
|
|
55
|
+
|
|
56
|
+
@a_plus.command("validate")
|
|
57
|
+
@click.argument("content-name")
|
|
58
|
+
@click.option("--data", "-d", type=click.Path(exists=True), required=True, help="JSON file with content data")
|
|
59
|
+
@click.pass_context
|
|
60
|
+
@handle_errors
|
|
61
|
+
def validate_content(ctx, content_name, data):
|
|
62
|
+
"""Validate A+ Content without creating."""
|
|
63
|
+
_, client = ensure_auth_client(ctx)
|
|
64
|
+
|
|
65
|
+
with open(data, "r") as f:
|
|
66
|
+
content_data = json.load(f)
|
|
67
|
+
|
|
68
|
+
content = build_content_from_json(content_name, content_data)
|
|
69
|
+
|
|
70
|
+
issues = content.validate()
|
|
71
|
+
if issues:
|
|
72
|
+
click.echo("Validation issues:", err=True)
|
|
73
|
+
for issue in issues:
|
|
74
|
+
click.echo(f" - {issue}", err=True)
|
|
75
|
+
raise click.Abort()
|
|
76
|
+
|
|
77
|
+
response = client.validate_a_plus_content(content.to_dict())
|
|
78
|
+
click.echo("API validation passed")
|
|
79
|
+
if response:
|
|
80
|
+
click.echo(json.dumps(response, indent=2))
|
|
81
|
+
|
|
82
|
+
@a_plus.command("get")
|
|
83
|
+
@click.argument("content-name")
|
|
84
|
+
@click.pass_context
|
|
85
|
+
@handle_errors
|
|
86
|
+
def get_content(ctx, content_name):
|
|
87
|
+
"""Get A+ Content document."""
|
|
88
|
+
_, client = ensure_auth_client(ctx)
|
|
89
|
+
response = client.get_a_plus_content(content_name)
|
|
90
|
+
click.echo(json.dumps(response, indent=2))
|
|
91
|
+
|
|
92
|
+
@a_plus.command("list")
|
|
93
|
+
@click.pass_context
|
|
94
|
+
@handle_errors
|
|
95
|
+
def list_content(ctx):
|
|
96
|
+
"""List A+ Content documents."""
|
|
97
|
+
_, client = ensure_auth_client(ctx)
|
|
98
|
+
response = client.list_a_plus_content(marketplaceId=client.marketplace_id)
|
|
99
|
+
documents = response.get("contentDocumentList", [])
|
|
100
|
+
click.echo(f"\nA+ Content Documents ({len(documents)} found):\n")
|
|
101
|
+
for doc in documents:
|
|
102
|
+
click.echo(f" Name: {doc.get('name')}")
|
|
103
|
+
click.echo(f" Status: {doc.get('status', 'N/A')}")
|
|
104
|
+
click.echo(f" Locale: {doc.get('locale', 'N/A')}")
|
|
105
|
+
asins = doc.get("asinSet", [])
|
|
106
|
+
if asins:
|
|
107
|
+
click.echo(f" ASINs: {', '.join(asins)}")
|
|
108
|
+
click.echo()
|
|
109
|
+
|
|
110
|
+
@a_plus.command("update")
|
|
111
|
+
@click.argument("content-name")
|
|
112
|
+
@click.option("--data", "-d", type=click.Path(exists=True), required=True, help="JSON file with updated content")
|
|
113
|
+
@click.pass_context
|
|
114
|
+
@handle_errors
|
|
115
|
+
def update_content(ctx, content_name, data):
|
|
116
|
+
"""Update existing A+ Content document."""
|
|
117
|
+
_, client = ensure_auth_client(ctx)
|
|
118
|
+
|
|
119
|
+
with open(data, "r") as f:
|
|
120
|
+
content_data = json.load(f)
|
|
121
|
+
|
|
122
|
+
content = build_content_from_json(content_name, content_data)
|
|
123
|
+
|
|
124
|
+
issues = content.validate()
|
|
125
|
+
if issues:
|
|
126
|
+
click.echo("Validation issues:", err=True)
|
|
127
|
+
for issue in issues:
|
|
128
|
+
click.echo(f" - {issue}", err=True)
|
|
129
|
+
raise click.Abort()
|
|
130
|
+
|
|
131
|
+
response = client.update_a_plus_content(content_name, content.to_dict())
|
|
132
|
+
click.echo(f"A+ Content updated: {content_name}")
|
|
133
|
+
if response:
|
|
134
|
+
click.echo(json.dumps(response, indent=2))
|
|
135
|
+
|
|
136
|
+
@a_plus.command("delete")
|
|
137
|
+
@click.argument("content-name")
|
|
138
|
+
@click.confirmation_option(prompt="Delete this A+ Content?")
|
|
139
|
+
@click.pass_context
|
|
140
|
+
@handle_errors
|
|
141
|
+
def delete_content(ctx, content_name):
|
|
142
|
+
"""Delete A+ Content document."""
|
|
143
|
+
_, client = ensure_auth_client(ctx)
|
|
144
|
+
client.delete_a_plus_content(content_name)
|
|
145
|
+
click.echo(f"A+ Content deleted: {content_name}")
|
|
146
|
+
|
|
147
|
+
@a_plus.command("upload-image")
|
|
148
|
+
@click.argument("file-path", type=click.Path(exists=True))
|
|
149
|
+
@click.option("--content-type", default="image/jpeg", help="MIME type of the image")
|
|
150
|
+
@click.option("--resource", default="aplus", help="Resource type for upload destination")
|
|
151
|
+
@click.pass_context
|
|
152
|
+
@handle_errors
|
|
153
|
+
def upload_image(ctx, file_path, content_type, resource):
|
|
154
|
+
"""Upload an image and return an uploadDestinationId for A+ Content."""
|
|
155
|
+
_, client = ensure_auth_client(ctx)
|
|
156
|
+
|
|
157
|
+
with open(file_path, "rb") as f:
|
|
158
|
+
file_data = f.read()
|
|
159
|
+
|
|
160
|
+
md5_hash = hashlib.md5(file_data).digest()
|
|
161
|
+
content_md5 = base64.b64encode(md5_hash).decode("ascii")
|
|
162
|
+
file_name = os.path.basename(file_path)
|
|
163
|
+
|
|
164
|
+
response = client.create_upload_destination(
|
|
165
|
+
marketplace_id=client.marketplace_id,
|
|
166
|
+
content_md5=content_md5,
|
|
167
|
+
content_type=content_type,
|
|
168
|
+
file_name=file_name,
|
|
169
|
+
resource=resource,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
payload = response.get("payload", response)
|
|
173
|
+
upload_destination_id = payload.get("uploadDestinationId")
|
|
174
|
+
upload_url = payload.get("url")
|
|
175
|
+
headers = payload.get("headers", [])
|
|
176
|
+
|
|
177
|
+
if not upload_url:
|
|
178
|
+
click.echo("Error: No upload URL returned", err=True)
|
|
179
|
+
raise click.Abort()
|
|
180
|
+
|
|
181
|
+
upload_headers = {h["name"]: h["value"] for h in headers}
|
|
182
|
+
upload_headers.setdefault("Content-Type", content_type)
|
|
183
|
+
upload_headers.setdefault("Content-MD5", content_md5)
|
|
184
|
+
|
|
185
|
+
put_response = requests.put(upload_url, data=file_data, headers=upload_headers)
|
|
186
|
+
put_response.raise_for_status()
|
|
187
|
+
|
|
188
|
+
click.echo(f"Upload successful: {file_name}")
|
|
189
|
+
click.echo(f"uploadDestinationId: {upload_destination_id}")
|
|
190
|
+
|
|
191
|
+
@a_plus.group("asin")
|
|
192
|
+
def asin():
|
|
193
|
+
"""ASIN association commands."""
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
@asin.command("add")
|
|
197
|
+
@click.argument("content-name")
|
|
198
|
+
@click.argument("asins", nargs=-1, required=True)
|
|
199
|
+
@click.pass_context
|
|
200
|
+
@handle_errors
|
|
201
|
+
def add_asins(ctx, content_name, asins):
|
|
202
|
+
"""Associate ASINs with A+ Content."""
|
|
203
|
+
_, client = ensure_auth_client(ctx)
|
|
204
|
+
response = client.post_a_plus_content_asin_relations(content_name, list(asins))
|
|
205
|
+
click.echo(f"Associated {len(asins)} ASIN(s) with {content_name}")
|
|
206
|
+
if response:
|
|
207
|
+
click.echo(json.dumps(response, indent=2))
|
|
208
|
+
|
|
209
|
+
@asin.command("remove")
|
|
210
|
+
@click.argument("content-name")
|
|
211
|
+
@click.argument("asins", nargs=-1, required=True)
|
|
212
|
+
@click.pass_context
|
|
213
|
+
@handle_errors
|
|
214
|
+
def remove_asins(ctx, content_name, asins):
|
|
215
|
+
"""Remove ASIN associations from A+ Content."""
|
|
216
|
+
_, client = ensure_auth_client(ctx)
|
|
217
|
+
response = client.delete_a_plus_content_asin_relations(content_name, list(asins))
|
|
218
|
+
click.echo(f"Removed {len(asins)} ASIN(s) from {content_name}")
|
|
219
|
+
if response:
|
|
220
|
+
click.echo(json.dumps(response, indent=2))
|
|
221
|
+
|
|
222
|
+
@asin.command("list")
|
|
223
|
+
@click.argument("content-name")
|
|
224
|
+
@click.pass_context
|
|
225
|
+
@handle_errors
|
|
226
|
+
def list_asins(ctx, content_name):
|
|
227
|
+
"""List ASINs associated with A+ Content."""
|
|
228
|
+
_, client = ensure_auth_client(ctx)
|
|
229
|
+
response = client.get_a_plus_content_asin_relations(content_name)
|
|
230
|
+
asins = response.get("asinSet", [])
|
|
231
|
+
click.echo(f"\nASINs for '{content_name}' ({len(asins)} found):\n")
|
|
232
|
+
for asin in asins:
|
|
233
|
+
click.echo(f" {asin}")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Authentication commands."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from ..cli import DEFAULT_CREDENTIALS_PATH, handle_errors
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_auth_commands(cli_group):
|
|
12
|
+
"""Register authentication CLI commands."""
|
|
13
|
+
|
|
14
|
+
@cli_group.group()
|
|
15
|
+
def auth():
|
|
16
|
+
"""Authentication commands."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@auth.command("setup")
|
|
20
|
+
@click.option("--path", default=DEFAULT_CREDENTIALS_PATH, help="Path to save credentials")
|
|
21
|
+
@click.option("--profile", default="default", help="Credential profile name")
|
|
22
|
+
@click.option("--refresh-token", help="Refresh token")
|
|
23
|
+
@click.option("--client-id", help="Client ID")
|
|
24
|
+
@click.option("--client-secret", help="Client secret")
|
|
25
|
+
@click.option("--aws-access-key-id", help="AWS Access Key ID")
|
|
26
|
+
@click.option("--aws-secret-access-key", help="AWS Secret Access Key")
|
|
27
|
+
@click.option("--seller-id", default="A2GKV2AN9F8YG3", help="Seller ID")
|
|
28
|
+
@click.option("--marketplace-id", default="ATVPDKIKX0DER", help="Marketplace ID")
|
|
29
|
+
@click.pass_context
|
|
30
|
+
def auth_setup(
|
|
31
|
+
ctx,
|
|
32
|
+
path,
|
|
33
|
+
profile,
|
|
34
|
+
refresh_token,
|
|
35
|
+
client_id,
|
|
36
|
+
client_secret,
|
|
37
|
+
aws_access_key_id,
|
|
38
|
+
aws_secret_access_key,
|
|
39
|
+
seller_id,
|
|
40
|
+
marketplace_id,
|
|
41
|
+
):
|
|
42
|
+
"""Set up Amazon SP-API credentials.
|
|
43
|
+
|
|
44
|
+
When flags are omitted, falls back to interactive prompts.
|
|
45
|
+
"""
|
|
46
|
+
click.echo("🔐 Amazon SP-API Credential Setup")
|
|
47
|
+
click.echo("=" * 50)
|
|
48
|
+
click.echo()
|
|
49
|
+
|
|
50
|
+
interactive = not all([refresh_token, client_id, client_secret, aws_access_key_id, aws_secret_access_key])
|
|
51
|
+
if interactive:
|
|
52
|
+
click.echo("You'll need the following from your Amazon Developer account:")
|
|
53
|
+
click.echo(" 1. Refresh Token (from LWA authorization)")
|
|
54
|
+
click.echo(" 2. Client ID (from your app registration)")
|
|
55
|
+
click.echo(" 3. Client Secret (from your app registration)")
|
|
56
|
+
click.echo(" 4. AWS Access Key ID")
|
|
57
|
+
click.echo(" 5. AWS Secret Access Key")
|
|
58
|
+
click.echo()
|
|
59
|
+
|
|
60
|
+
profile = profile or click.prompt("Profile name", default="default")
|
|
61
|
+
refresh_token = refresh_token or click.prompt("Refresh token", hide_input=True)
|
|
62
|
+
client_id = client_id or click.prompt("Client ID")
|
|
63
|
+
client_secret = client_secret or click.prompt("Client secret", hide_input=True)
|
|
64
|
+
aws_access_key_id = aws_access_key_id or click.prompt("AWS Access Key ID")
|
|
65
|
+
aws_secret_access_key = aws_secret_access_key or click.prompt("AWS Secret Access Key", hide_input=True)
|
|
66
|
+
|
|
67
|
+
credentials = {
|
|
68
|
+
"version": "1.0",
|
|
69
|
+
profile: {
|
|
70
|
+
"refresh_token": refresh_token,
|
|
71
|
+
"client_id": client_id,
|
|
72
|
+
"client_secret": client_secret,
|
|
73
|
+
"aws_access_key_id": aws_access_key_id,
|
|
74
|
+
"aws_secret_access_key": aws_secret_access_key,
|
|
75
|
+
"seller_id": seller_id,
|
|
76
|
+
"marketplace_id": marketplace_id,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Merge with existing if present
|
|
81
|
+
if os.path.exists(path):
|
|
82
|
+
try:
|
|
83
|
+
with open(path, "r") as f:
|
|
84
|
+
existing = yaml.safe_load(f) or {}
|
|
85
|
+
existing[profile] = credentials[profile]
|
|
86
|
+
credentials = existing
|
|
87
|
+
click.echo(f"\n📝 Merged with existing credentials at {path}")
|
|
88
|
+
except Exception as e:
|
|
89
|
+
click.echo(f"⚠️ Could not read existing file: {e}")
|
|
90
|
+
|
|
91
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
92
|
+
with open(path, "w") as f:
|
|
93
|
+
yaml.dump(credentials, f, default_flow_style=False, sort_keys=False)
|
|
94
|
+
|
|
95
|
+
click.echo(f"✅ Credentials saved to {path}")
|
|
96
|
+
click.echo(f" Profile: {profile}")
|
|
97
|
+
click.echo(f" Seller ID: {seller_id}")
|
|
98
|
+
click.echo(f" Marketplace ID: {marketplace_id}")
|
|
99
|
+
click.echo()
|
|
100
|
+
click.echo("You can now use: python -m amazon_sp_cli.main --profile {profile} get-price <sku>")
|
|
101
|
+
|
|
102
|
+
@auth.command("show")
|
|
103
|
+
@click.option("--path", default=DEFAULT_CREDENTIALS_PATH, help="Path to credentials file")
|
|
104
|
+
@click.pass_context
|
|
105
|
+
def auth_show(ctx, path):
|
|
106
|
+
"""Show configured profiles (without secrets)."""
|
|
107
|
+
if not os.path.exists(path):
|
|
108
|
+
click.echo(f"❌ No credentials file found at {path}")
|
|
109
|
+
click.echo("Run: python -m amazon_sp_cli.main auth setup")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
with open(path, "r") as f:
|
|
113
|
+
creds = yaml.safe_load(f) or {}
|
|
114
|
+
|
|
115
|
+
click.echo(f"\n📄 Credentials file: {path}")
|
|
116
|
+
click.echo("-" * 40)
|
|
117
|
+
|
|
118
|
+
for profile, data in creds.items():
|
|
119
|
+
if profile == "version":
|
|
120
|
+
continue
|
|
121
|
+
click.echo(f"Profile: {profile}")
|
|
122
|
+
click.echo(f" Client ID: {data.get('client_id', 'N/A')[:20]}...")
|
|
123
|
+
click.echo(f" Seller ID: {data.get('seller_id', 'N/A')}")
|
|
124
|
+
click.echo(f" Marketplace ID: {data.get('marketplace_id', 'N/A')}")
|
|
125
|
+
click.echo()
|
|
126
|
+
|
|
127
|
+
@cli_group.command()
|
|
128
|
+
@click.pass_context
|
|
129
|
+
@handle_errors
|
|
130
|
+
def invalidate(ctx):
|
|
131
|
+
"""Invalidate cached access token."""
|
|
132
|
+
from ..cli import _ensure_auth_client
|
|
133
|
+
|
|
134
|
+
auth, _ = _ensure_auth_client(ctx)
|
|
135
|
+
auth.invalidate()
|