cli-web-amazon 0.1.1__py3-none-any.whl
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.
- cli_web/amazon/README.md +83 -0
- cli_web/amazon/__init__.py +3 -0
- cli_web/amazon/__main__.py +6 -0
- cli_web/amazon/amazon_cli.py +155 -0
- cli_web/amazon/commands/__init__.py +1 -0
- cli_web/amazon/commands/bestsellers.py +61 -0
- cli_web/amazon/commands/product.py +36 -0
- cli_web/amazon/commands/search.py +45 -0
- cli_web/amazon/commands/suggest.py +36 -0
- cli_web/amazon/core/__init__.py +1 -0
- cli_web/amazon/core/client.py +416 -0
- cli_web/amazon/core/exceptions.py +76 -0
- cli_web/amazon/core/models.py +63 -0
- cli_web/amazon/skills/SKILL.md +105 -0
- cli_web/amazon/tests/TEST.md +173 -0
- cli_web/amazon/tests/__init__.py +1 -0
- cli_web/amazon/tests/test_core.py +369 -0
- cli_web/amazon/tests/test_e2e.py +355 -0
- cli_web/amazon/utils/__init__.py +1 -0
- cli_web/amazon/utils/config.py +5 -0
- cli_web/amazon/utils/doctor.py +188 -0
- cli_web/amazon/utils/helpers.py +127 -0
- cli_web/amazon/utils/mcp_server.py +290 -0
- cli_web/amazon/utils/output.py +130 -0
- cli_web/amazon/utils/repl_skin.py +486 -0
- cli_web_amazon-0.1.1.dist-info/METADATA +14 -0
- cli_web_amazon-0.1.1.dist-info/RECORD +30 -0
- cli_web_amazon-0.1.1.dist-info/WHEEL +5 -0
- cli_web_amazon-0.1.1.dist-info/entry_points.txt +2 -0
- cli_web_amazon-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""E2E and subprocess tests for cli-web-amazon."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# _resolve_cli helper
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve_cli(name: str):
|
|
17
|
+
"""Resolve installed CLI command; falls back to python -m for dev."""
|
|
18
|
+
force = os.environ.get("CLI_WEB_FORCE_INSTALLED", "").strip() == "1"
|
|
19
|
+
path = shutil.which(name)
|
|
20
|
+
if path:
|
|
21
|
+
return [path]
|
|
22
|
+
if force:
|
|
23
|
+
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
|
24
|
+
module = name.replace("cli-web-", "cli_web.") + "." + name.split("-")[-1] + "_cli"
|
|
25
|
+
return [sys.executable, "-m", module]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
CLI = _resolve_cli("cli-web-amazon")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Live E2E — public commands (no auth required)
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.e2e
|
|
37
|
+
class TestE2ESuggest:
|
|
38
|
+
"""cli-web-amazon suggest — live autocomplete API."""
|
|
39
|
+
|
|
40
|
+
def test_suggest_returns_results(self):
|
|
41
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
42
|
+
|
|
43
|
+
with AmazonClient() as client:
|
|
44
|
+
suggestions = client.get_suggestions("laptop")
|
|
45
|
+
assert len(suggestions) > 0, "Suggest returned empty list for 'laptop'"
|
|
46
|
+
assert suggestions[0].value, "First suggestion has no value"
|
|
47
|
+
assert suggestions[0].type, "First suggestion has no type"
|
|
48
|
+
|
|
49
|
+
def test_suggest_keyword_type(self):
|
|
50
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
51
|
+
|
|
52
|
+
with AmazonClient() as client:
|
|
53
|
+
suggestions = client.get_suggestions("headphones")
|
|
54
|
+
types = {s.type for s in suggestions}
|
|
55
|
+
assert "KEYWORD" in types, f"Expected KEYWORD type in suggestions; got: {types}"
|
|
56
|
+
|
|
57
|
+
def test_suggest_no_rpc_leakage(self):
|
|
58
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
59
|
+
|
|
60
|
+
with AmazonClient() as client:
|
|
61
|
+
suggestions = client.get_suggestions("phone")
|
|
62
|
+
for s in suggestions:
|
|
63
|
+
assert "wrb.fr" not in s.value, "Raw RPC data leaked into suggestion"
|
|
64
|
+
assert "af.httprm" not in s.value, "Raw RPC data leaked into suggestion"
|
|
65
|
+
|
|
66
|
+
def test_suggest_unusual_query_returns_list(self):
|
|
67
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
68
|
+
|
|
69
|
+
with AmazonClient() as client:
|
|
70
|
+
suggestions = client.get_suggestions("xyzzy12345noresults")
|
|
71
|
+
# May return empty list — just must not raise
|
|
72
|
+
assert isinstance(suggestions, list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.e2e
|
|
76
|
+
class TestE2ESearch:
|
|
77
|
+
"""cli-web-amazon search — live HTML search."""
|
|
78
|
+
|
|
79
|
+
def test_search_returns_asins(self):
|
|
80
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
81
|
+
|
|
82
|
+
with AmazonClient() as client:
|
|
83
|
+
results = client.search("laptop")
|
|
84
|
+
assert len(results) > 0, "Search returned no results for 'laptop'"
|
|
85
|
+
for r in results:
|
|
86
|
+
assert len(r.asin) == 10, f"ASIN {r.asin!r} does not look like a valid ASIN"
|
|
87
|
+
|
|
88
|
+
def test_search_result_fields(self):
|
|
89
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
90
|
+
|
|
91
|
+
with AmazonClient() as client:
|
|
92
|
+
results = client.search("laptop")
|
|
93
|
+
first = results[0]
|
|
94
|
+
assert first.title, "First search result has no title"
|
|
95
|
+
assert first.url.startswith("https://www.amazon.com"), f"URL looks wrong: {first.url}"
|
|
96
|
+
|
|
97
|
+
def test_search_url_uses_asin(self):
|
|
98
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
99
|
+
|
|
100
|
+
with AmazonClient() as client:
|
|
101
|
+
results = client.search("laptop")
|
|
102
|
+
first = results[0]
|
|
103
|
+
assert first.asin in first.url, "ASIN not present in product URL"
|
|
104
|
+
|
|
105
|
+
def test_search_pagination(self):
|
|
106
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
107
|
+
|
|
108
|
+
with AmazonClient() as client:
|
|
109
|
+
page1 = client.search("laptop", page=1)
|
|
110
|
+
page2 = client.search("laptop", page=2)
|
|
111
|
+
# Both pages should have results; they should not be identical
|
|
112
|
+
assert len(page1) > 0
|
|
113
|
+
assert len(page2) > 0
|
|
114
|
+
asin1 = {r.asin for r in page1}
|
|
115
|
+
asin2 = {r.asin for r in page2}
|
|
116
|
+
assert asin1 != asin2, "Page 1 and page 2 returned identical ASINs"
|
|
117
|
+
|
|
118
|
+
def test_search_no_rpc_leakage(self):
|
|
119
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
120
|
+
|
|
121
|
+
with AmazonClient() as client:
|
|
122
|
+
results = client.search("laptop")
|
|
123
|
+
for r in results:
|
|
124
|
+
assert "wrb.fr" not in r.title, "Raw RPC data in search title"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.e2e
|
|
128
|
+
class TestE2EProduct:
|
|
129
|
+
"""cli-web-amazon product get — live product detail page."""
|
|
130
|
+
|
|
131
|
+
KNOWN_ASIN = "B0GRZ78683" # Dell Inspiron 15 (stable listing)
|
|
132
|
+
|
|
133
|
+
def test_get_product_returns_data(self):
|
|
134
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
135
|
+
|
|
136
|
+
with AmazonClient() as client:
|
|
137
|
+
product = client.get_product(self.KNOWN_ASIN)
|
|
138
|
+
assert product.asin == self.KNOWN_ASIN
|
|
139
|
+
assert product.title, "Product title is empty"
|
|
140
|
+
assert len(product.title) > 10, "Product title suspiciously short"
|
|
141
|
+
|
|
142
|
+
def test_get_product_url_is_dp_url(self):
|
|
143
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
144
|
+
|
|
145
|
+
with AmazonClient() as client:
|
|
146
|
+
product = client.get_product(self.KNOWN_ASIN)
|
|
147
|
+
assert self.KNOWN_ASIN in product.url, "ASIN not in product URL"
|
|
148
|
+
assert product.url.startswith("https://www.amazon.com"), "URL doesn't start with amazon.com"
|
|
149
|
+
|
|
150
|
+
def test_get_product_rating_format(self):
|
|
151
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
152
|
+
|
|
153
|
+
with AmazonClient() as client:
|
|
154
|
+
product = client.get_product(self.KNOWN_ASIN)
|
|
155
|
+
if product.rating:
|
|
156
|
+
assert "out of 5" in product.rating, f"Unexpected rating format: {product.rating}"
|
|
157
|
+
|
|
158
|
+
def test_search_then_get_round_trip(self):
|
|
159
|
+
"""Search → pick first ASIN → get product → verify title matches."""
|
|
160
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
161
|
+
|
|
162
|
+
with AmazonClient() as client:
|
|
163
|
+
results = client.search("laptop")
|
|
164
|
+
assert len(results) > 0
|
|
165
|
+
asin = results[0].asin
|
|
166
|
+
product = client.get_product(asin)
|
|
167
|
+
assert product.asin == asin, "Product ASIN mismatch"
|
|
168
|
+
assert product.title, "Product returned by get has no title"
|
|
169
|
+
|
|
170
|
+
def test_get_product_no_rpc_leakage(self):
|
|
171
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
172
|
+
|
|
173
|
+
with AmazonClient() as client:
|
|
174
|
+
product = client.get_product(self.KNOWN_ASIN)
|
|
175
|
+
assert "wrb.fr" not in product.title, "Raw RPC data in product title"
|
|
176
|
+
assert "af.httprm" not in (product.title + str(product.price)), "Raw RPC data in product"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.e2e
|
|
180
|
+
class TestE2EBestSellers:
|
|
181
|
+
"""cli-web-amazon bestsellers — live bestseller page."""
|
|
182
|
+
|
|
183
|
+
def test_bestsellers_electronics(self):
|
|
184
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
185
|
+
|
|
186
|
+
with AmazonClient() as client:
|
|
187
|
+
items = client.get_bestsellers("electronics")
|
|
188
|
+
assert len(items) > 0, "Bestsellers returned no items for electronics"
|
|
189
|
+
assert items[0].rank == 1, f"First item rank should be 1, got {items[0].rank}"
|
|
190
|
+
|
|
191
|
+
def test_bestsellers_asin_length(self):
|
|
192
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
193
|
+
|
|
194
|
+
with AmazonClient() as client:
|
|
195
|
+
items = client.get_bestsellers("electronics")
|
|
196
|
+
for item in items:
|
|
197
|
+
assert len(item.asin) == 10, f"ASIN {item.asin!r} not 10 chars"
|
|
198
|
+
|
|
199
|
+
def test_bestsellers_rank_sequential(self):
|
|
200
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
201
|
+
|
|
202
|
+
with AmazonClient() as client:
|
|
203
|
+
items = client.get_bestsellers("electronics")
|
|
204
|
+
ranks = [i.rank for i in items]
|
|
205
|
+
assert ranks == sorted(ranks), "Ranks are not in ascending order"
|
|
206
|
+
assert ranks[0] == 1, "First rank is not 1"
|
|
207
|
+
|
|
208
|
+
def test_bestsellers_title_not_empty(self):
|
|
209
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
210
|
+
|
|
211
|
+
with AmazonClient() as client:
|
|
212
|
+
items = client.get_bestsellers("books")
|
|
213
|
+
for item in items:
|
|
214
|
+
assert item.title, f"Item with ASIN {item.asin} has empty title"
|
|
215
|
+
|
|
216
|
+
def test_bestsellers_url_contains_asin(self):
|
|
217
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
218
|
+
|
|
219
|
+
with AmazonClient() as client:
|
|
220
|
+
items = client.get_bestsellers("electronics")
|
|
221
|
+
for item in items:
|
|
222
|
+
assert item.asin in item.url, f"ASIN {item.asin} not in URL {item.url}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Subprocess tests — full CLI invocations
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@pytest.mark.e2e
|
|
231
|
+
class TestCLISubprocess:
|
|
232
|
+
"""End-to-end subprocess tests using the installed cli-web-amazon binary."""
|
|
233
|
+
|
|
234
|
+
def _run(self, args, check=False):
|
|
235
|
+
return subprocess.run(
|
|
236
|
+
CLI + args,
|
|
237
|
+
capture_output=True,
|
|
238
|
+
text=True,
|
|
239
|
+
encoding="utf-8",
|
|
240
|
+
errors="replace",
|
|
241
|
+
check=check,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def test_help_loads(self):
|
|
245
|
+
result = self._run(["--help"])
|
|
246
|
+
assert result.returncode == 0
|
|
247
|
+
assert "cli-web-amazon" in result.stdout.lower() or "amazon" in result.stdout.lower()
|
|
248
|
+
|
|
249
|
+
def test_version(self):
|
|
250
|
+
result = self._run(["--version"])
|
|
251
|
+
assert result.returncode == 0
|
|
252
|
+
|
|
253
|
+
def test_search_json_output(self):
|
|
254
|
+
result = self._run(["search", "laptop", "--json"])
|
|
255
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
256
|
+
data = json.loads(result.stdout)
|
|
257
|
+
assert isinstance(data, list), "Expected JSON array"
|
|
258
|
+
assert len(data) > 0, "Search returned empty list"
|
|
259
|
+
first = data[0]
|
|
260
|
+
assert "asin" in first
|
|
261
|
+
assert "title" in first
|
|
262
|
+
assert len(first["asin"]) == 10, "ASIN not 10 chars"
|
|
263
|
+
|
|
264
|
+
def test_search_no_rpc_leak(self):
|
|
265
|
+
result = self._run(["search", "laptop", "--json"])
|
|
266
|
+
assert result.returncode == 0
|
|
267
|
+
data = json.loads(result.stdout)
|
|
268
|
+
for item in data:
|
|
269
|
+
assert "wrb.fr" not in item.get("title", ""), "Raw RPC data in title"
|
|
270
|
+
assert "af.httprm" not in item.get("title", ""), "Raw RPC data in title"
|
|
271
|
+
|
|
272
|
+
def test_suggest_json_output(self):
|
|
273
|
+
result = self._run(["suggest", "laptop", "--json"])
|
|
274
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
275
|
+
data = json.loads(result.stdout)
|
|
276
|
+
assert isinstance(data, list)
|
|
277
|
+
assert len(data) > 0
|
|
278
|
+
assert "value" in data[0]
|
|
279
|
+
assert "type" in data[0]
|
|
280
|
+
|
|
281
|
+
def test_product_get_json_output(self):
|
|
282
|
+
result = self._run(["product", "get", "B0GRZ78683", "--json"])
|
|
283
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
284
|
+
data = json.loads(result.stdout)
|
|
285
|
+
assert data["asin"] == "B0GRZ78683"
|
|
286
|
+
assert data["title"], "Product title is empty"
|
|
287
|
+
assert "url" in data
|
|
288
|
+
assert "B0GRZ78683" in data["url"]
|
|
289
|
+
|
|
290
|
+
def test_product_get_no_rpc_leak(self):
|
|
291
|
+
result = self._run(["product", "get", "B0GRZ78683", "--json"])
|
|
292
|
+
assert result.returncode == 0
|
|
293
|
+
data = json.loads(result.stdout)
|
|
294
|
+
assert "wrb.fr" not in data.get("title", ""), "Raw RPC data in product title"
|
|
295
|
+
|
|
296
|
+
def test_bestsellers_json_output(self):
|
|
297
|
+
result = self._run(["bestsellers", "electronics", "--json"])
|
|
298
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
299
|
+
data = json.loads(result.stdout)
|
|
300
|
+
assert isinstance(data, list)
|
|
301
|
+
assert len(data) > 0
|
|
302
|
+
assert data[0]["rank"] == 1
|
|
303
|
+
|
|
304
|
+
def test_bestsellers_required_fields(self):
|
|
305
|
+
result = self._run(["bestsellers", "electronics", "--json"])
|
|
306
|
+
assert result.returncode == 0
|
|
307
|
+
data = json.loads(result.stdout)
|
|
308
|
+
for item in data:
|
|
309
|
+
assert "asin" in item
|
|
310
|
+
assert "title" in item
|
|
311
|
+
assert "rank" in item
|
|
312
|
+
|
|
313
|
+
def test_search_with_page(self):
|
|
314
|
+
result = self._run(["search", "laptop", "--page", "2", "--json"])
|
|
315
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
316
|
+
data = json.loads(result.stdout)
|
|
317
|
+
assert isinstance(data, list)
|
|
318
|
+
|
|
319
|
+
def test_search_with_dept(self):
|
|
320
|
+
result = self._run(["search", "laptop", "--dept", "electronics", "--json"])
|
|
321
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
322
|
+
data = json.loads(result.stdout)
|
|
323
|
+
assert isinstance(data, list)
|
|
324
|
+
|
|
325
|
+
def test_product_get_unknown_asin_error(self):
|
|
326
|
+
"""Unknown ASIN should return a structured error, not a crash."""
|
|
327
|
+
result = self._run(["product", "get", "BADASIN000", "--json"])
|
|
328
|
+
# May return error or empty product — should not crash
|
|
329
|
+
if result.returncode != 0:
|
|
330
|
+
try:
|
|
331
|
+
data = json.loads(result.stdout)
|
|
332
|
+
assert "error" in data
|
|
333
|
+
except json.JSONDecodeError:
|
|
334
|
+
pytest.fail("CLI crashed with non-JSON output on unknown ASIN")
|
|
335
|
+
|
|
336
|
+
def test_search_help_subcommand(self):
|
|
337
|
+
result = self._run(["search", "--help"])
|
|
338
|
+
assert result.returncode == 0
|
|
339
|
+
assert "search" in result.stdout.lower()
|
|
340
|
+
|
|
341
|
+
def test_product_help_subcommand(self):
|
|
342
|
+
result = self._run(["product", "--help"])
|
|
343
|
+
assert result.returncode == 0
|
|
344
|
+
|
|
345
|
+
def test_bestsellers_help_subcommand(self):
|
|
346
|
+
result = self._run(["bestsellers", "--help"])
|
|
347
|
+
assert result.returncode == 0
|
|
348
|
+
|
|
349
|
+
def test_suggest_help_subcommand(self):
|
|
350
|
+
result = self._run(["suggest", "--help"])
|
|
351
|
+
assert result.returncode == 0
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utilities for cli-web-amazon."""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""``doctor`` — self-diagnosis for cli-web-* CLIs.
|
|
2
|
+
|
|
3
|
+
CANONICAL SOURCE: cli-web-core/cli_web_core/doctor.py
|
|
4
|
+
Vendored into every generated CLI at cli_web/<app>/utils/doctor.py by
|
|
5
|
+
`cli-web-devkit resync`. Do not edit vendored copies by hand.
|
|
6
|
+
|
|
7
|
+
Checks the local environment a support thread would ask about first:
|
|
8
|
+
installation, Python version, config directory, auth material (when the
|
|
9
|
+
CLI has an auth module), and optional dependencies. Read-only — never
|
|
10
|
+
mutates state, never touches the network.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import stat
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DoctorCheck:
|
|
28
|
+
name: str
|
|
29
|
+
status: str # "ok" | "warn" | "fail"
|
|
30
|
+
detail: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_entry_point(app_name: str) -> DoctorCheck:
|
|
34
|
+
binary = f"cli-web-{app_name}"
|
|
35
|
+
path = shutil.which(binary)
|
|
36
|
+
if path:
|
|
37
|
+
return DoctorCheck("entry point", "ok", path)
|
|
38
|
+
return DoctorCheck(
|
|
39
|
+
"entry point",
|
|
40
|
+
"warn",
|
|
41
|
+
f"{binary} not on PATH — run `pip install -e .` in agent-harness/ "
|
|
42
|
+
f"(python -m fallback still works)",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check_python() -> DoctorCheck:
|
|
47
|
+
# Intentional runtime guard: direct-source runs bypass pip's
|
|
48
|
+
# python_requires, so the interpreter check must live here.
|
|
49
|
+
if sys.version_info >= (3, 10): # noqa: UP036
|
|
50
|
+
return DoctorCheck("python", "ok", sys.version.split()[0])
|
|
51
|
+
return DoctorCheck("python", "fail", f"{sys.version.split()[0]} < 3.10 (unsupported)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _config_dir(app_name: str) -> Path:
|
|
55
|
+
return Path.home() / ".config" / f"cli-web-{app_name}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_config_dir(app_name: str) -> DoctorCheck:
|
|
59
|
+
cfg = _config_dir(app_name)
|
|
60
|
+
if not cfg.exists():
|
|
61
|
+
return DoctorCheck("config dir", "ok", f"{cfg} (not created yet — created on first use)")
|
|
62
|
+
if os.access(cfg, os.W_OK):
|
|
63
|
+
return DoctorCheck("config dir", "ok", str(cfg))
|
|
64
|
+
return DoctorCheck("config dir", "fail", f"{cfg} is not writable")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_auth_module(pkg: str) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
return importlib.util.find_spec(f"cli_web.{pkg}.core.auth") is not None
|
|
70
|
+
except (ImportError, ModuleNotFoundError, ValueError):
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_auth(app_name: str, pkg: str) -> list[DoctorCheck]:
|
|
75
|
+
if not _has_auth_module(pkg):
|
|
76
|
+
return [DoctorCheck("auth", "ok", "no auth module — public site, nothing to configure")]
|
|
77
|
+
|
|
78
|
+
checks: list[DoctorCheck] = []
|
|
79
|
+
if importlib.util.find_spec("playwright") is None:
|
|
80
|
+
checks.append(
|
|
81
|
+
DoctorCheck(
|
|
82
|
+
"playwright",
|
|
83
|
+
"warn",
|
|
84
|
+
"not installed — `auth login` (browser flow) unavailable; "
|
|
85
|
+
"pip install playwright && playwright install chromium",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
checks.append(DoctorCheck("playwright", "ok", "installed"))
|
|
90
|
+
|
|
91
|
+
env_var = f"CLI_WEB_{app_name.upper().replace('-', '_')}_AUTH_JSON"
|
|
92
|
+
if os.environ.get(env_var):
|
|
93
|
+
checks.append(DoctorCheck("auth source", "ok", f"using env var {env_var}"))
|
|
94
|
+
return checks
|
|
95
|
+
|
|
96
|
+
auth_file = _config_dir(app_name) / "auth.json"
|
|
97
|
+
if not auth_file.is_file():
|
|
98
|
+
checks.append(
|
|
99
|
+
DoctorCheck(
|
|
100
|
+
"auth file",
|
|
101
|
+
"warn",
|
|
102
|
+
f"{auth_file} missing — run: cli-web-{app_name} auth login (or set {env_var})",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return checks
|
|
106
|
+
|
|
107
|
+
checks.append(DoctorCheck("auth file", "ok", str(auth_file)))
|
|
108
|
+
if os.name == "posix": # st_mode permission bits are meaningless on Windows
|
|
109
|
+
mode = stat.S_IMODE(auth_file.stat().st_mode)
|
|
110
|
+
if mode & 0o077:
|
|
111
|
+
checks.append(
|
|
112
|
+
DoctorCheck(
|
|
113
|
+
"auth file permissions",
|
|
114
|
+
"warn",
|
|
115
|
+
f"{oct(mode)} — should be 600; run: chmod 600 {auth_file}",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
checks.append(DoctorCheck("auth file permissions", "ok", oct(mode)))
|
|
120
|
+
try:
|
|
121
|
+
json.loads(auth_file.read_text(encoding="utf-8"))
|
|
122
|
+
checks.append(DoctorCheck("auth file format", "ok", "valid JSON"))
|
|
123
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
124
|
+
checks.append(DoctorCheck("auth file format", "fail", f"unreadable: {exc}"))
|
|
125
|
+
|
|
126
|
+
return checks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_optional_deps() -> list[DoctorCheck]:
|
|
130
|
+
checks = []
|
|
131
|
+
if importlib.util.find_spec("prompt_toolkit") is None:
|
|
132
|
+
checks.append(
|
|
133
|
+
DoctorCheck("prompt_toolkit", "ok", "not installed — REPL uses plain input()")
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
checks.append(DoctorCheck("prompt_toolkit", "ok", "installed (REPL autocomplete on)"))
|
|
137
|
+
return checks
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def run_doctor(app_name: str, pkg: str) -> list[DoctorCheck]:
|
|
141
|
+
checks = [
|
|
142
|
+
_check_python(),
|
|
143
|
+
_check_entry_point(app_name),
|
|
144
|
+
_check_config_dir(app_name),
|
|
145
|
+
*_check_auth(app_name, pkg),
|
|
146
|
+
*_check_optional_deps(),
|
|
147
|
+
]
|
|
148
|
+
return checks
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def register_doctor_command(cli: Any, app_name: str, pkg: str | None = None) -> None:
|
|
152
|
+
"""Attach a ``doctor`` command to a cli-web-* Click group."""
|
|
153
|
+
import click
|
|
154
|
+
|
|
155
|
+
resolved_pkg = pkg or app_name.replace("-", "_")
|
|
156
|
+
|
|
157
|
+
@cli.command("doctor")
|
|
158
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
159
|
+
@click.pass_context
|
|
160
|
+
def doctor(ctx: Any, json_mode: bool) -> None:
|
|
161
|
+
"""Diagnose this CLI's local setup (install, auth, dependencies)."""
|
|
162
|
+
if not json_mode: # honor the group-level --json flag (ctx.obj["json"])
|
|
163
|
+
obj = ctx.find_root().obj
|
|
164
|
+
json_mode = bool(obj.get("json")) if isinstance(obj, dict) else False
|
|
165
|
+
checks = run_doctor(app_name, resolved_pkg)
|
|
166
|
+
failed = [c for c in checks if c.status == "fail"]
|
|
167
|
+
if json_mode:
|
|
168
|
+
click.echo(
|
|
169
|
+
json.dumps(
|
|
170
|
+
{
|
|
171
|
+
"success": not failed,
|
|
172
|
+
"data": {
|
|
173
|
+
"checks": [asdict(c) for c in checks],
|
|
174
|
+
"ok": not failed,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
indent=2,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
marks = {"ok": "✓", "warn": "⚠", "fail": "✗"}
|
|
182
|
+
for c in checks:
|
|
183
|
+
detail = f" {c.detail}" if c.detail else ""
|
|
184
|
+
click.echo(f" {marks[c.status]} {c.name}:{detail}")
|
|
185
|
+
click.echo()
|
|
186
|
+
click.echo("all good" if not failed else f"{len(failed)} problem(s) found")
|
|
187
|
+
if failed:
|
|
188
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Shared CLI helpers for cli-web-amazon."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..core.exceptions import (
|
|
12
|
+
AmazonError,
|
|
13
|
+
ParsingError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
error_code_for,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
CONFIG_DIR = Path.home() / ".config" / "cli-web-amazon"
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# JSON output helper
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_json(data: Any) -> None:
|
|
28
|
+
"""Print data as pretty JSON."""
|
|
29
|
+
click.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Error handler context manager
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def handle_errors(json_mode: bool = False):
|
|
39
|
+
"""Context manager that catches exceptions and outputs proper error messages.
|
|
40
|
+
|
|
41
|
+
Exit codes: 1=user/app error, 2=system error, 130=keyboard interrupt.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
yield
|
|
45
|
+
except KeyboardInterrupt:
|
|
46
|
+
if not json_mode:
|
|
47
|
+
click.echo("\nInterrupted.", err=True)
|
|
48
|
+
sys.exit(130)
|
|
49
|
+
except click.exceptions.Exit:
|
|
50
|
+
raise
|
|
51
|
+
except click.UsageError:
|
|
52
|
+
raise
|
|
53
|
+
except AmazonError as exc:
|
|
54
|
+
code = error_code_for(exc)
|
|
55
|
+
if json_mode:
|
|
56
|
+
err_dict: dict = {"error": True, "code": code, "message": str(exc)}
|
|
57
|
+
if isinstance(exc, RateLimitError) and exc.retry_after is not None:
|
|
58
|
+
err_dict["retry_after"] = exc.retry_after
|
|
59
|
+
click.echo(json.dumps(err_dict, ensure_ascii=False))
|
|
60
|
+
else:
|
|
61
|
+
hint = ""
|
|
62
|
+
if isinstance(exc, RateLimitError) and exc.retry_after:
|
|
63
|
+
hint = f"\n Hint: Retry after {exc.retry_after:.0f}s"
|
|
64
|
+
elif isinstance(exc, ParsingError):
|
|
65
|
+
hint = "\n Hint: Amazon page structure may have changed"
|
|
66
|
+
click.echo(f"Error: {exc}{hint}", err=True)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
if json_mode:
|
|
70
|
+
click.echo(
|
|
71
|
+
json.dumps(
|
|
72
|
+
{"error": True, "code": "INTERNAL_ERROR", "message": str(exc)},
|
|
73
|
+
ensure_ascii=False,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
click.echo(f"Error: {exc}", err=True)
|
|
78
|
+
sys.exit(2)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Filename sanitization
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
_INVALID_CHARS = set('/\\:*?"<>|')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def sanitize_filename(name: str, max_length: int = 240) -> str:
|
|
89
|
+
"""Convert a title to a safe filename."""
|
|
90
|
+
if not name or not name.strip():
|
|
91
|
+
return "untitled"
|
|
92
|
+
safe = "".join(c if c not in _INVALID_CHARS else "_" for c in name)
|
|
93
|
+
safe = safe.strip(". ")
|
|
94
|
+
return safe[:max_length] if safe else "untitled"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Persistent config
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _load_config() -> dict:
|
|
103
|
+
"""Load config.json, returning empty dict on failure."""
|
|
104
|
+
try:
|
|
105
|
+
if CONFIG_FILE.exists():
|
|
106
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
107
|
+
except (json.JSONDecodeError, OSError):
|
|
108
|
+
pass
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _save_config(data: dict) -> None:
|
|
113
|
+
"""Save config.json."""
|
|
114
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_config_value(key: str) -> Any:
|
|
119
|
+
"""Get a value from persistent config."""
|
|
120
|
+
return _load_config().get(key)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def set_config_value(key: str, value: Any) -> None:
|
|
124
|
+
"""Set a value in persistent config."""
|
|
125
|
+
cfg = _load_config()
|
|
126
|
+
cfg[key] = value
|
|
127
|
+
_save_config(cfg)
|