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.
@@ -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