cli-web-reddit 0.1.0__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/reddit/README.md +68 -0
- cli_web/reddit/__init__.py +3 -0
- cli_web/reddit/__main__.py +6 -0
- cli_web/reddit/commands/__init__.py +0 -0
- cli_web/reddit/commands/actions.py +268 -0
- cli_web/reddit/commands/auth_cmd.py +73 -0
- cli_web/reddit/commands/feed.py +115 -0
- cli_web/reddit/commands/me.py +139 -0
- cli_web/reddit/commands/post.py +93 -0
- cli_web/reddit/commands/search.py +66 -0
- cli_web/reddit/commands/subreddit.py +184 -0
- cli_web/reddit/commands/user.py +90 -0
- cli_web/reddit/core/__init__.py +0 -0
- cli_web/reddit/core/auth.py +204 -0
- cli_web/reddit/core/client.py +475 -0
- cli_web/reddit/core/exceptions.py +63 -0
- cli_web/reddit/core/models.py +253 -0
- cli_web/reddit/reddit_cli.py +174 -0
- cli_web/reddit/skills/SKILL.md +143 -0
- cli_web/reddit/tests/TEST.md +109 -0
- cli_web/reddit/tests/__init__.py +0 -0
- cli_web/reddit/tests/conftest.py +9 -0
- cli_web/reddit/tests/test_core.py +568 -0
- cli_web/reddit/tests/test_e2e.py +312 -0
- cli_web/reddit/utils/__init__.py +0 -0
- cli_web/reddit/utils/doctor.py +188 -0
- cli_web/reddit/utils/helpers.py +91 -0
- cli_web/reddit/utils/mcp_server.py +290 -0
- cli_web/reddit/utils/output.py +133 -0
- cli_web/reddit/utils/repl_skin.py +486 -0
- cli_web_reddit-0.1.0.dist-info/METADATA +15 -0
- cli_web_reddit-0.1.0.dist-info/RECORD +35 -0
- cli_web_reddit-0.1.0.dist-info/WHEEL +5 -0
- cli_web_reddit-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_reddit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""E2E live tests and subprocess tests for cli-web-reddit.
|
|
2
|
+
|
|
3
|
+
These tests hit the real Reddit JSON API. No auth required (public read-only).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from cli_web.reddit.core.client import RedditClient
|
|
16
|
+
|
|
17
|
+
# ── _resolve_cli helper ──────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve_cli(name):
|
|
21
|
+
"""Resolve installed CLI command; falls back to python -m for dev."""
|
|
22
|
+
force = os.environ.get("CLI_WEB_FORCE_INSTALLED", "").strip() == "1"
|
|
23
|
+
path = shutil.which(name)
|
|
24
|
+
if path:
|
|
25
|
+
print(f"[_resolve_cli] Using installed command: {path}")
|
|
26
|
+
return [path]
|
|
27
|
+
if force:
|
|
28
|
+
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
|
29
|
+
module = "cli_web.reddit.reddit_cli"
|
|
30
|
+
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
|
|
31
|
+
return [sys.executable, "-m", module]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Helpers ──────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _posts_from_listing(data: dict) -> list[dict]:
|
|
38
|
+
"""Extract post dicts from a Reddit listing response."""
|
|
39
|
+
return [child["data"] for child in data["data"]["children"]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _assert_post_fields(post: dict) -> None:
|
|
43
|
+
"""Verify a post dict has the required fields."""
|
|
44
|
+
for field in ("id", "title", "subreddit", "author", "score", "num_comments"):
|
|
45
|
+
assert field in post, f"Missing field: {field}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Live API: Feed ───────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.live
|
|
52
|
+
class TestFeedLive:
|
|
53
|
+
"""Live tests for feed endpoints (no auth)."""
|
|
54
|
+
|
|
55
|
+
@pytest.fixture(autouse=True)
|
|
56
|
+
def setup_client(self):
|
|
57
|
+
self.client = RedditClient()
|
|
58
|
+
yield
|
|
59
|
+
self.client.close()
|
|
60
|
+
|
|
61
|
+
def test_feed_hot(self):
|
|
62
|
+
data = self.client.feed_hot(limit=3)
|
|
63
|
+
posts = _posts_from_listing(data)
|
|
64
|
+
assert len(posts) > 0
|
|
65
|
+
for p in posts:
|
|
66
|
+
_assert_post_fields(p)
|
|
67
|
+
print(f"[verify] feed_hot returned {len(posts)} posts, first: {posts[0]['title'][:60]}")
|
|
68
|
+
|
|
69
|
+
def test_feed_top(self):
|
|
70
|
+
data = self.client.feed_top(limit=3, time="day")
|
|
71
|
+
posts = _posts_from_listing(data)
|
|
72
|
+
assert len(posts) > 0
|
|
73
|
+
for p in posts:
|
|
74
|
+
_assert_post_fields(p)
|
|
75
|
+
print(f"[verify] feed_top returned {len(posts)} posts")
|
|
76
|
+
|
|
77
|
+
def test_feed_popular(self):
|
|
78
|
+
data = self.client.feed_popular(limit=3)
|
|
79
|
+
posts = _posts_from_listing(data)
|
|
80
|
+
assert len(posts) > 0
|
|
81
|
+
for p in posts:
|
|
82
|
+
_assert_post_fields(p)
|
|
83
|
+
print(f"[verify] feed_popular returned {len(posts)} posts")
|
|
84
|
+
|
|
85
|
+
def test_pagination(self):
|
|
86
|
+
"""Fetch page 1, grab cursor, fetch page 2 — verify different posts."""
|
|
87
|
+
page1 = self.client.feed_hot(limit=3)
|
|
88
|
+
posts1 = _posts_from_listing(page1)
|
|
89
|
+
assert len(posts1) > 0
|
|
90
|
+
|
|
91
|
+
after = page1["data"].get("after")
|
|
92
|
+
assert after, "Expected pagination cursor in response"
|
|
93
|
+
|
|
94
|
+
page2 = self.client.feed_hot(limit=3, after=after)
|
|
95
|
+
posts2 = _posts_from_listing(page2)
|
|
96
|
+
assert len(posts2) > 0
|
|
97
|
+
|
|
98
|
+
ids1 = {p["id"] for p in posts1}
|
|
99
|
+
ids2 = {p["id"] for p in posts2}
|
|
100
|
+
assert ids1 != ids2, "Page 1 and page 2 should have different posts"
|
|
101
|
+
print(f"[verify] Pagination OK: page1 ids={ids1}, page2 ids={ids2}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Live API: Subreddit ──────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.live
|
|
108
|
+
class TestSubredditLive:
|
|
109
|
+
"""Live tests for subreddit endpoints."""
|
|
110
|
+
|
|
111
|
+
@pytest.fixture(autouse=True)
|
|
112
|
+
def setup_client(self):
|
|
113
|
+
self.client = RedditClient()
|
|
114
|
+
yield
|
|
115
|
+
self.client.close()
|
|
116
|
+
|
|
117
|
+
def test_sub_posts(self):
|
|
118
|
+
data = self.client.sub_posts("python", limit=3)
|
|
119
|
+
posts = _posts_from_listing(data)
|
|
120
|
+
assert len(posts) > 0
|
|
121
|
+
for p in posts:
|
|
122
|
+
_assert_post_fields(p)
|
|
123
|
+
assert p["subreddit"].lower() == "python"
|
|
124
|
+
print(f"[verify] r/python returned {len(posts)} posts")
|
|
125
|
+
|
|
126
|
+
def test_sub_info(self):
|
|
127
|
+
data = self.client.sub_info("python")
|
|
128
|
+
info = data["data"]
|
|
129
|
+
assert info["display_name"].lower() == "python"
|
|
130
|
+
assert "subscribers" in info
|
|
131
|
+
assert info["subscribers"] > 0
|
|
132
|
+
print(f"[verify] r/python: {info['subscribers']} subscribers")
|
|
133
|
+
|
|
134
|
+
def test_sub_rules(self):
|
|
135
|
+
data = self.client.sub_rules("python")
|
|
136
|
+
assert "rules" in data
|
|
137
|
+
rules = data["rules"]
|
|
138
|
+
assert len(rules) > 0
|
|
139
|
+
for rule in rules:
|
|
140
|
+
assert "short_name" in rule
|
|
141
|
+
print(f"[verify] r/python has {len(rules)} rules")
|
|
142
|
+
|
|
143
|
+
def test_list_get_roundtrip(self):
|
|
144
|
+
"""List posts, then get one by ID via post_detail — verify fields match."""
|
|
145
|
+
listing = self.client.sub_posts("python", limit=3)
|
|
146
|
+
posts = _posts_from_listing(listing)
|
|
147
|
+
assert len(posts) > 0
|
|
148
|
+
|
|
149
|
+
target = posts[0]
|
|
150
|
+
post_id = target["id"]
|
|
151
|
+
subreddit = target["subreddit"]
|
|
152
|
+
|
|
153
|
+
detail = self.client.post_detail(subreddit, post_id)
|
|
154
|
+
assert isinstance(detail, list)
|
|
155
|
+
assert len(detail) >= 1
|
|
156
|
+
detail_post = detail[0]["data"]["children"][0]["data"]
|
|
157
|
+
assert detail_post["id"] == post_id
|
|
158
|
+
assert detail_post["title"] == target["title"]
|
|
159
|
+
print(f"[verify] Roundtrip OK: post {post_id} title matches")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Live API: Search ─────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.live
|
|
166
|
+
class TestSearchLive:
|
|
167
|
+
"""Live tests for search endpoints."""
|
|
168
|
+
|
|
169
|
+
@pytest.fixture(autouse=True)
|
|
170
|
+
def setup_client(self):
|
|
171
|
+
self.client = RedditClient()
|
|
172
|
+
yield
|
|
173
|
+
self.client.close()
|
|
174
|
+
|
|
175
|
+
def test_search_posts(self):
|
|
176
|
+
data = self.client.search_posts("python", limit=3)
|
|
177
|
+
posts = _posts_from_listing(data)
|
|
178
|
+
assert len(posts) > 0
|
|
179
|
+
for p in posts:
|
|
180
|
+
_assert_post_fields(p)
|
|
181
|
+
print(f"[verify] search_posts('python') returned {len(posts)} results")
|
|
182
|
+
|
|
183
|
+
def test_search_subreddits(self):
|
|
184
|
+
data = self.client.search_subreddits("python", limit=3)
|
|
185
|
+
subs = [child["data"] for child in data["data"]["children"]]
|
|
186
|
+
assert len(subs) > 0
|
|
187
|
+
for s in subs:
|
|
188
|
+
assert "display_name" in s
|
|
189
|
+
print(f"[verify] search_subreddits('python') returned {len(subs)} subreddits")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── Live API: User ───────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.mark.live
|
|
196
|
+
class TestUserLive:
|
|
197
|
+
"""Live tests for user endpoints."""
|
|
198
|
+
|
|
199
|
+
@pytest.fixture(autouse=True)
|
|
200
|
+
def setup_client(self):
|
|
201
|
+
self.client = RedditClient()
|
|
202
|
+
yield
|
|
203
|
+
self.client.close()
|
|
204
|
+
|
|
205
|
+
def test_user_about(self):
|
|
206
|
+
data = self.client.user_about("spez")
|
|
207
|
+
info = data["data"]
|
|
208
|
+
assert info["name"].lower() == "spez"
|
|
209
|
+
assert "link_karma" in info
|
|
210
|
+
print(f"[verify] u/spez: link_karma={info['link_karma']}")
|
|
211
|
+
|
|
212
|
+
def test_user_posts(self):
|
|
213
|
+
data = self.client.user_posts("spez", limit=3)
|
|
214
|
+
posts = _posts_from_listing(data)
|
|
215
|
+
assert len(posts) > 0
|
|
216
|
+
for p in posts:
|
|
217
|
+
assert p["author"].lower() == "spez"
|
|
218
|
+
print(f"[verify] u/spez has {len(posts)} posts returned")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ── Subprocess tests ─────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@pytest.mark.subprocess
|
|
225
|
+
class TestCLISubprocess:
|
|
226
|
+
"""Test the CLI binary via subprocess (installed or python -m fallback)."""
|
|
227
|
+
|
|
228
|
+
@pytest.fixture(autouse=True)
|
|
229
|
+
def setup_cmd(self):
|
|
230
|
+
self.cmd = _resolve_cli("cli-web-reddit")
|
|
231
|
+
|
|
232
|
+
def _run(self, *args, check: bool = True) -> subprocess.CompletedProcess:
|
|
233
|
+
result = subprocess.run(
|
|
234
|
+
[*self.cmd, *args],
|
|
235
|
+
capture_output=True,
|
|
236
|
+
encoding="utf-8",
|
|
237
|
+
errors="replace",
|
|
238
|
+
timeout=60,
|
|
239
|
+
)
|
|
240
|
+
if check and result.returncode != 0:
|
|
241
|
+
print(f"[stderr] {result.stderr}")
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
def test_help(self):
|
|
245
|
+
r = self._run("--help")
|
|
246
|
+
assert r.returncode == 0
|
|
247
|
+
out = r.stdout.lower()
|
|
248
|
+
assert "feed" in out
|
|
249
|
+
assert "sub" in out
|
|
250
|
+
assert "search" in out
|
|
251
|
+
assert "user" in out
|
|
252
|
+
print("[verify] --help lists all command groups")
|
|
253
|
+
|
|
254
|
+
def test_version(self):
|
|
255
|
+
r = self._run("--version")
|
|
256
|
+
assert r.returncode == 0
|
|
257
|
+
assert "0.2.0" in r.stdout
|
|
258
|
+
print(f"[verify] --version: {r.stdout.strip()}")
|
|
259
|
+
|
|
260
|
+
def test_feed_hot_json(self):
|
|
261
|
+
r = self._run("feed", "hot", "--limit", "3", "--json")
|
|
262
|
+
assert r.returncode == 0
|
|
263
|
+
data = json.loads(r.stdout)
|
|
264
|
+
assert "posts" in data or isinstance(data, list)
|
|
265
|
+
# Accept either {"posts": [...]} or [...] depending on output format
|
|
266
|
+
posts = data.get("posts", data) if isinstance(data, dict) else data
|
|
267
|
+
assert len(posts) > 0
|
|
268
|
+
print(f"[verify] feed hot --json returned {len(posts)} posts")
|
|
269
|
+
|
|
270
|
+
def test_search_posts_json(self):
|
|
271
|
+
r = self._run("search", "posts", "python", "--limit", "3", "--json")
|
|
272
|
+
assert r.returncode == 0
|
|
273
|
+
data = json.loads(r.stdout)
|
|
274
|
+
# Flexible: accept list or dict with posts key
|
|
275
|
+
if isinstance(data, dict):
|
|
276
|
+
posts = data.get("posts", data.get("results", []))
|
|
277
|
+
else:
|
|
278
|
+
posts = data
|
|
279
|
+
assert len(posts) > 0
|
|
280
|
+
print(f"[verify] search posts --json returned {len(posts)} results")
|
|
281
|
+
|
|
282
|
+
def test_sub_info_json(self):
|
|
283
|
+
r = self._run("sub", "info", "python", "--json")
|
|
284
|
+
assert r.returncode == 0
|
|
285
|
+
data = json.loads(r.stdout)
|
|
286
|
+
assert isinstance(data, dict)
|
|
287
|
+
# Should contain subreddit name field
|
|
288
|
+
name = data.get("display_name", data.get("name", ""))
|
|
289
|
+
assert name.lower() == "python"
|
|
290
|
+
print(f"[verify] sub info python --json: {name}")
|
|
291
|
+
|
|
292
|
+
def test_user_info_json(self):
|
|
293
|
+
r = self._run("user", "info", "spez", "--json")
|
|
294
|
+
assert r.returncode == 0
|
|
295
|
+
data = json.loads(r.stdout)
|
|
296
|
+
assert isinstance(data, dict)
|
|
297
|
+
name = data.get("name", "")
|
|
298
|
+
assert name.lower() == "spez"
|
|
299
|
+
print(f"[verify] user info spez --json: {name}")
|
|
300
|
+
|
|
301
|
+
def test_human_readable_output(self):
|
|
302
|
+
"""Non-JSON output should produce human-readable table/text."""
|
|
303
|
+
r = self._run("feed", "hot", "--limit", "3")
|
|
304
|
+
assert r.returncode == 0
|
|
305
|
+
out = r.stdout
|
|
306
|
+
# Should have some text output, not raw JSON
|
|
307
|
+
assert len(out.strip()) > 0
|
|
308
|
+
# Should NOT start with '{' or '[' (not raw JSON)
|
|
309
|
+
stripped = out.strip()
|
|
310
|
+
assert not stripped.startswith("{"), "Expected human-readable output, got JSON"
|
|
311
|
+
assert not stripped.startswith("["), "Expected human-readable output, got JSON"
|
|
312
|
+
print(f"[verify] Human-readable output: {len(out)} chars")
|
|
File without changes
|
|
@@ -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,91 @@
|
|
|
1
|
+
"""Shared CLI helpers for cli-web-reddit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..core.exceptions import (
|
|
12
|
+
AuthError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
RedditError,
|
|
17
|
+
ServerError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_json_mode(json_mode: bool) -> bool:
|
|
22
|
+
"""Honor explicit --json or inherit it from the root CLI context."""
|
|
23
|
+
if json_mode:
|
|
24
|
+
return True
|
|
25
|
+
ctx = click.get_current_context(silent=True)
|
|
26
|
+
root = ctx.find_root() if ctx else None
|
|
27
|
+
if root and isinstance(root.obj, dict):
|
|
28
|
+
return bool(root.obj.get("json", False))
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def json_error(code: str, message: str, **extra) -> str:
|
|
33
|
+
"""Format an error as JSON string."""
|
|
34
|
+
return json.dumps({"error": True, "code": code, "message": message, **extra})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def handle_errors(json_mode: bool = False):
|
|
39
|
+
"""Context manager for consistent error handling across all commands."""
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
except AuthError as exc:
|
|
43
|
+
if json_mode:
|
|
44
|
+
click.echo(json_error("AUTH_EXPIRED", str(exc)))
|
|
45
|
+
else:
|
|
46
|
+
click.echo(f"Error: {exc}", err=True)
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
except NotFoundError as exc:
|
|
49
|
+
if json_mode:
|
|
50
|
+
click.echo(json_error("NOT_FOUND", str(exc)))
|
|
51
|
+
else:
|
|
52
|
+
click.echo(f"Error: {exc}", err=True)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
except RateLimitError as exc:
|
|
55
|
+
if json_mode:
|
|
56
|
+
click.echo(json_error("RATE_LIMITED", str(exc), retry_after=exc.retry_after))
|
|
57
|
+
else:
|
|
58
|
+
click.echo(f"Error: {exc}", err=True)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
except ServerError as exc:
|
|
61
|
+
if json_mode:
|
|
62
|
+
click.echo(json_error("SERVER_ERROR", str(exc)))
|
|
63
|
+
else:
|
|
64
|
+
click.echo(f"Error: {exc}", err=True)
|
|
65
|
+
sys.exit(2)
|
|
66
|
+
except NetworkError as exc:
|
|
67
|
+
if json_mode:
|
|
68
|
+
click.echo(json_error("NETWORK_ERROR", str(exc)))
|
|
69
|
+
else:
|
|
70
|
+
click.echo(f"Error: {exc}", err=True)
|
|
71
|
+
sys.exit(2)
|
|
72
|
+
except RedditError as exc:
|
|
73
|
+
if json_mode:
|
|
74
|
+
click.echo(json_error("ERROR", str(exc)))
|
|
75
|
+
else:
|
|
76
|
+
click.echo(f"Error: {exc}", err=True)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
sys.exit(130)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def print_json(data) -> None:
|
|
83
|
+
"""Print data as formatted JSON."""
|
|
84
|
+
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def truncate(text: str | None, length: int = 50) -> str:
|
|
88
|
+
"""Truncate text to a maximum length."""
|
|
89
|
+
if not text:
|
|
90
|
+
return ""
|
|
91
|
+
return text[:length] + "..." if len(text) > length else text
|