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,253 @@
1
+ """Response models for Reddit API data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ def _ts(utc: float | None) -> str:
9
+ """Convert Unix timestamp to human-readable string."""
10
+ if utc is None:
11
+ return ""
12
+ return datetime.fromtimestamp(utc, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
13
+
14
+
15
+ def _compact_number(n: int) -> str:
16
+ """Format large numbers compactly: 1234 -> 1.2k, 1234567 -> 1.2M."""
17
+ if n >= 1_000_000:
18
+ return f"{n / 1_000_000:.1f}M"
19
+ if n >= 1_000:
20
+ return f"{n / 1_000:.1f}k"
21
+ return str(n)
22
+
23
+
24
+ def format_post_summary(child: dict) -> dict:
25
+ """Extract key fields from a post (t3) for display."""
26
+ d = child.get("data", child)
27
+ return {
28
+ "id": d.get("id", ""),
29
+ "title": d.get("title", ""),
30
+ "author": d.get("author", "[deleted]"),
31
+ "subreddit": d.get("subreddit", ""),
32
+ "score": d.get("score", 0),
33
+ "num_comments": d.get("num_comments", 0),
34
+ "upvote_ratio": d.get("upvote_ratio", 0),
35
+ "created": _ts(d.get("created_utc")),
36
+ "url": d.get("url", ""),
37
+ "permalink": f"https://www.reddit.com{d.get('permalink', '')}",
38
+ "is_self": d.get("is_self", False),
39
+ "over_18": d.get("over_18", False),
40
+ "stickied": d.get("stickied", False),
41
+ "flair": d.get("link_flair_text") or "",
42
+ "selftext": d.get("selftext", ""),
43
+ }
44
+
45
+
46
+ def format_post_detail(
47
+ post_data: dict,
48
+ comments_data: dict | None = None,
49
+ more_children_fn=None,
50
+ link_id: str = "",
51
+ thread_fn=None,
52
+ post_id: str = "",
53
+ ) -> dict:
54
+ """Full post detail with comments for --json output.
55
+
56
+ Args:
57
+ more_children_fn: Optional callable(link_id, child_ids) -> list[dict]
58
+ to fetch collapsed 'more' comment objects.
59
+ link_id: Post fullname (t3_xxx) for fetching 'more' children.
60
+ thread_fn: Optional callable(post_id, comment_id) -> list to fetch
61
+ 'continue this thread' deep comment chains.
62
+ post_id: Post ID (without t3_ prefix) for thread_fn calls.
63
+ """
64
+ post = format_post_summary(post_data)
65
+ post["selftext"] = (post_data.get("data", post_data)).get("selftext", "")
66
+
67
+ comments: list[dict] = []
68
+ more_ids: list[str] = []
69
+ continue_thread_parents: list[str] = []
70
+ if comments_data:
71
+ _collect_comments(
72
+ comments_data.get("data", {}).get("children", []),
73
+ comments,
74
+ more_ids,
75
+ continue_thread_parents,
76
+ )
77
+
78
+ # Fetch collapsed 'more' comments (have IDs)
79
+ if more_ids and more_children_fn and link_id:
80
+ try:
81
+ extra = more_children_fn(link_id, more_ids)
82
+ for thing in extra:
83
+ if thing.get("kind") == "t1":
84
+ comments.append(format_comment(thing))
85
+ except Exception:
86
+ pass
87
+
88
+ # Fetch 'continue this thread' deep chains (empty IDs)
89
+ if continue_thread_parents and thread_fn and post_id:
90
+ for parent_id in continue_thread_parents[:5]: # Limit to 5 expansions
91
+ try:
92
+ # parent_id is like "t1_od80pbe" — strip prefix for comment_id
93
+ comment_id = parent_id.replace("t1_", "")
94
+ thread_data = thread_fn(post_id, comment_id)
95
+ if len(thread_data) > 1:
96
+ thread_comments: list[dict] = []
97
+ _collect_comments(
98
+ thread_data[1].get("data", {}).get("children", []),
99
+ thread_comments,
100
+ )
101
+ # Only add comments we don't already have
102
+ existing_ids = {c["id"] for c in comments}
103
+ for c in thread_comments:
104
+ if c["id"] not in existing_ids:
105
+ comments.append(c)
106
+ except Exception:
107
+ pass
108
+
109
+ post["comments"] = comments
110
+ return post
111
+
112
+
113
+ def _collect_comments(
114
+ children: list[dict],
115
+ comments: list[dict],
116
+ more_ids: list[str] | None = None,
117
+ continue_thread_parents: list[str] | None = None,
118
+ ) -> None:
119
+ """Flatten Reddit comment trees while preserving depth.
120
+
121
+ Collects 'more' object child IDs into more_ids for later fetching.
122
+ Collects parent IDs of 'continue this thread' links into continue_thread_parents.
123
+ """
124
+ for child in children:
125
+ kind = child.get("kind")
126
+ if kind == "t1":
127
+ comments.append(format_comment(child))
128
+ replies = child.get("data", {}).get("replies")
129
+ if isinstance(replies, dict):
130
+ _collect_comments(
131
+ replies.get("data", {}).get("children", []),
132
+ comments,
133
+ more_ids,
134
+ continue_thread_parents,
135
+ )
136
+ elif kind == "more":
137
+ more_data = child.get("data", {})
138
+ child_ids = more_data.get("children", [])
139
+ if child_ids and more_ids is not None:
140
+ # Normal collapsed comments — fetch via morechildren API
141
+ more_ids.extend(child_ids)
142
+ elif not child_ids and continue_thread_parents is not None:
143
+ # "Continue this thread" — empty IDs, need permalink fetch
144
+ parent = more_data.get("parent_id", "")
145
+ if parent:
146
+ continue_thread_parents.append(parent)
147
+
148
+
149
+ def format_comment(child: dict) -> dict:
150
+ """Extract key fields from a comment (t1)."""
151
+ d = child.get("data", child)
152
+ return {
153
+ "id": d.get("id", ""),
154
+ "parent_id": d.get("parent_id", ""),
155
+ "author": d.get("author", "[deleted]"),
156
+ "body": d.get("body", ""),
157
+ "score": d.get("score", 0),
158
+ "created": _ts(d.get("created_utc")),
159
+ "is_submitter": d.get("is_submitter", False),
160
+ "depth": d.get("depth", 0),
161
+ }
162
+
163
+
164
+ def format_subreddit_info(data: dict) -> dict:
165
+ """Extract key fields from a subreddit (t5)."""
166
+ d = data.get("data", data)
167
+ return {
168
+ "name": d.get("display_name", ""),
169
+ "title": d.get("title", ""),
170
+ "description": d.get("public_description", ""),
171
+ "subscribers": d.get("subscribers", 0),
172
+ "active_users": d.get("active_user_count") or d.get("accounts_active", 0),
173
+ "created": _ts(d.get("created_utc")),
174
+ "over_18": d.get("over18", False),
175
+ "type": d.get("subreddit_type", "public"),
176
+ "url": f"https://www.reddit.com/r/{d.get('display_name', '')}",
177
+ }
178
+
179
+
180
+ def format_subreddit_search(child: dict) -> dict:
181
+ """Extract key fields from a subreddit search result."""
182
+ d = child.get("data", child)
183
+ return {
184
+ "name": d.get("display_name", ""),
185
+ "title": d.get("title", ""),
186
+ "description": (d.get("public_description") or "")[:100],
187
+ "subscribers": d.get("subscribers", 0),
188
+ "over_18": d.get("over18", False),
189
+ }
190
+
191
+
192
+ def format_user_info(data: dict) -> dict:
193
+ """Extract key fields from a user (t2)."""
194
+ d = data.get("data", data)
195
+ return {
196
+ "name": d.get("name", ""),
197
+ "link_karma": d.get("link_karma", 0),
198
+ "comment_karma": d.get("comment_karma", 0),
199
+ "total_karma": d.get("total_karma", 0),
200
+ "created": _ts(d.get("created_utc")),
201
+ "is_gold": d.get("is_gold", False),
202
+ "verified": d.get("has_verified_email", False),
203
+ "url": f"https://www.reddit.com/user/{d.get('name', '')}",
204
+ }
205
+
206
+
207
+ def extract_listing_posts(response: dict) -> tuple[list[dict], str | None]:
208
+ """Extract posts from a Listing response. Returns (posts, after_cursor)."""
209
+ data = response.get("data", {})
210
+ children = data.get("children", [])
211
+ posts = [format_post_summary(c) for c in children if c.get("kind") == "t3"]
212
+ after = data.get("after")
213
+ return posts, after
214
+
215
+
216
+ def extract_listing_posts_and_comments(response: dict) -> tuple[list[dict], list[dict], str | None]:
217
+ """Extract posts AND comments from a mixed Listing (e.g., saved items).
218
+
219
+ Returns (posts, comments, after_cursor).
220
+ """
221
+ data = response.get("data", {})
222
+ children = data.get("children", [])
223
+ posts = [format_post_summary(c) for c in children if c.get("kind") == "t3"]
224
+ comments = [format_saved_comment(c) for c in children if c.get("kind") == "t1"]
225
+ after = data.get("after")
226
+ return posts, comments, after
227
+
228
+
229
+ def format_saved_comment(child: dict) -> dict:
230
+ """Format a saved comment (t1) with subreddit and permalink."""
231
+ d = child.get("data", child)
232
+ comment = format_comment(child)
233
+ comment["subreddit"] = d.get("subreddit", "")
234
+ comment["permalink"] = f"https://www.reddit.com{d.get('permalink', '')}"
235
+ return comment
236
+
237
+
238
+ def extract_listing_comments(response: dict) -> tuple[list[dict], str | None]:
239
+ """Extract comments from a Listing response."""
240
+ data = response.get("data", {})
241
+ children = data.get("children", [])
242
+ comments = [format_comment(c) for c in children if c.get("kind") == "t1"]
243
+ after = data.get("after")
244
+ return comments, after
245
+
246
+
247
+ def extract_listing_subreddits(response: dict) -> tuple[list[dict], str | None]:
248
+ """Extract subreddits from a Listing response."""
249
+ data = response.get("data", {})
250
+ children = data.get("children", [])
251
+ subs = [format_subreddit_search(c) for c in children if c.get("kind") == "t5"]
252
+ after = data.get("after")
253
+ return subs, after
@@ -0,0 +1,174 @@
1
+ """cli-web-reddit — CLI for Reddit browsing, search, and interaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import sys
7
+
8
+ # Windows UTF-8 fix
9
+ for _stream in (sys.stdout, sys.stderr):
10
+ if _stream.encoding and _stream.encoding.lower() not in ("utf-8", "utf8"):
11
+ try:
12
+ _stream.reconfigure(encoding="utf-8", errors="replace")
13
+ except AttributeError:
14
+ pass
15
+
16
+ import click
17
+
18
+ from .commands.actions import comment_group, saved_group, submit, vote
19
+ from .commands.auth_cmd import auth
20
+ from .commands.feed import feed
21
+ from .commands.me import me
22
+ from .commands.post import post
23
+ from .commands.search import search
24
+ from .commands.subreddit import sub
25
+ from .commands.user import user
26
+ from .utils.repl_skin import ReplSkin
27
+
28
+ _skin = ReplSkin("reddit", version="0.1.0")
29
+
30
+
31
+ @click.group(invoke_without_command=True)
32
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
33
+ @click.option("--version", is_flag=True, help="Show version.")
34
+ @click.pass_context
35
+ def cli(ctx, json_mode, version):
36
+ """Reddit browsing, search, and interaction CLI."""
37
+ ctx.ensure_object(dict)
38
+ ctx.obj["json"] = json_mode
39
+ if version:
40
+ click.echo("cli-web-reddit 0.1.0")
41
+ return
42
+ if ctx.invoked_subcommand is None:
43
+ _repl(ctx)
44
+
45
+
46
+ cli.add_command(feed)
47
+ cli.add_command(sub)
48
+ cli.add_command(search)
49
+ cli.add_command(user)
50
+ cli.add_command(post)
51
+ cli.add_command(auth)
52
+ cli.add_command(me)
53
+ cli.add_command(vote)
54
+ cli.add_command(submit)
55
+ cli.add_command(comment_group, "comment")
56
+ cli.add_command(saved_group, "saved")
57
+
58
+
59
+ def _print_repl_help():
60
+ """Print REPL help listing all commands and key options."""
61
+ _skin.info("Available commands:")
62
+ print()
63
+ _skin.section("Browse (no login needed)")
64
+ print(" feed hot [--limit N] [--after CURSOR]")
65
+ print(" feed new [--limit N] [--after CURSOR]")
66
+ print(" feed top [--time hour|day|week|month|year|all] [--limit N]")
67
+ print(" feed rising [--limit N] [--after CURSOR]")
68
+ print(" feed popular [--limit N] [--after CURSOR]")
69
+ print()
70
+ print(" sub hot <name> [--limit N] [--after CURSOR]")
71
+ print(" sub new <name> [--limit N] [--after CURSOR]")
72
+ print(" sub top <name> [--time day|week|month|year|all] [--limit N]")
73
+ print(" sub info <name> Subreddit details and stats")
74
+ print(" sub rules <name> Subreddit rules")
75
+ print(" sub search <name> <query> [--sort relevance|hot|top|new|comments] [--limit N]")
76
+ print()
77
+ print(" search posts <query> [--sort relevance|hot|top|new|comments]")
78
+ print(" [--time hour|day|week|month|year|all] [--limit N]")
79
+ print(" search subs <query> [--limit N] [--after CURSOR]")
80
+ print()
81
+ print(" user info <username> User profile")
82
+ print(" user posts <username> [--sort hot|new|top] [--limit N]")
83
+ print(" user comments <username> [--sort hot|new|top] [--limit N]")
84
+ print()
85
+ print(" post get <url_or_id> [--sub <name>] [--comments N]")
86
+ print()
87
+ _skin.section("Account (login required)")
88
+ print(" auth login Open browser to log in")
89
+ print(" auth logout Remove saved credentials")
90
+ print(" auth status Check login status")
91
+ print()
92
+ print(" me profile Your Reddit profile")
93
+ print(" me saved [--limit N] Your saved posts")
94
+ print(" me upvoted [--limit N] Your upvoted posts")
95
+ print(" me subscriptions Your subscribed subreddits")
96
+ print(" me inbox [--limit N] Your inbox messages")
97
+ print()
98
+ print(" vote up <id> Upvote post/comment (t3_xxx or t1_xxx)")
99
+ print(" vote down <id> Downvote post/comment")
100
+ print(" vote unvote <id> Remove vote")
101
+ print()
102
+ print(" submit flairs <sub> List available post flairs")
103
+ print(" submit text <sub> <title> <body> Submit a text post [--flair ID]")
104
+ print(" submit link <sub> <title> <url> Submit a link post [--flair ID]")
105
+ print()
106
+ print(" comment add <id> <text> Comment on a post or reply to comment")
107
+ print(" comment edit <id> <text> Edit your post/comment")
108
+ print(" comment delete <id> Delete your post/comment")
109
+ print()
110
+ print(" saved save <id> Save a post/comment")
111
+ print(" saved unsave <id> Unsave a post/comment")
112
+ print()
113
+ print(" sub join <name> Subscribe to subreddit")
114
+ print(" sub leave <name> Unsubscribe from subreddit")
115
+ print()
116
+ print(" help Show this help")
117
+ print(" quit / exit Exit REPL")
118
+ print()
119
+
120
+
121
+ def _repl(ctx):
122
+ """Interactive REPL mode."""
123
+ _skin.print_banner()
124
+ pt_session = _skin.create_prompt_session()
125
+
126
+ while True:
127
+ try:
128
+ line = _skin.get_input(pt_session)
129
+ if not line:
130
+ continue
131
+ if line.lower() in ("quit", "exit", "q"):
132
+ _skin.print_goodbye()
133
+ break
134
+ if line.lower() in ("help", "?"):
135
+ _print_repl_help()
136
+ continue
137
+
138
+ try:
139
+ args = shlex.split(line)
140
+ except ValueError as exc:
141
+ _skin.error(f"Invalid input: {exc}")
142
+ continue
143
+
144
+ repl_args = ["--json"] + args if ctx.obj.get("json") else args
145
+ try:
146
+ cli.main(args=repl_args, standalone_mode=False)
147
+ except SystemExit:
148
+ pass
149
+ except click.exceptions.UsageError as exc:
150
+ _skin.error(str(exc))
151
+ except Exception as exc:
152
+ _skin.error(str(exc))
153
+
154
+ except (KeyboardInterrupt, EOFError):
155
+ _skin.print_goodbye()
156
+ break
157
+
158
+
159
+ def main():
160
+ cli()
161
+
162
+
163
+ # MCP server mode — exposes every command as an MCP tool over stdio.
164
+ # Canonical adapter: cli-web-core/cli_web_core/mcp_server.py (vendored copy).
165
+ from cli_web.reddit import __version__ as _pkg_version # noqa: E402
166
+ from cli_web.reddit.utils.doctor import register_doctor_command # noqa: E402
167
+ from cli_web.reddit.utils.mcp_server import register_mcp_command # noqa: E402
168
+
169
+ register_mcp_command(cli, app_name="reddit", version=_pkg_version)
170
+ register_doctor_command(cli, app_name="reddit", pkg="reddit")
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()
@@ -0,0 +1,143 @@
1
+ ---
2
+ name: reddit-cli
3
+ description: Use cli-web-reddit to browse Reddit feeds, subreddits, search posts, view user profiles, and (with auth) vote, comment, submit posts, save items, and manage subscriptions. Always prefer cli-web-reddit over manually fetching the Reddit website.
4
+ ---
5
+
6
+ # cli-web-reddit
7
+
8
+ Reddit CLI — browse feeds, subreddits, search, user profiles, and full write operations with auth.
9
+
10
+ ## Quick Start
11
+
12
+ ```bash
13
+ pip install cli-web-reddit
14
+ cli-web-reddit feed hot --limit 5 --json
15
+ cli-web-reddit search posts "python async" --json
16
+ cli-web-reddit sub info python --json
17
+ ```
18
+
19
+ ## Commands
20
+
21
+ ### Feed (no auth)
22
+
23
+ ```bash
24
+ cli-web-reddit feed hot [--limit N] [--after CURSOR] --json
25
+ cli-web-reddit feed new [--limit N] [--after CURSOR] --json
26
+ cli-web-reddit feed top [--time hour|day|week|month|year|all] [--limit N] --json
27
+ cli-web-reddit feed rising [--limit N] --json
28
+ cli-web-reddit feed popular [--limit N] --json
29
+ ```
30
+
31
+ Output: `{"posts": [{"id", "title", "author", "subreddit", "score", "num_comments", "url", "permalink", "flair", "created"}], "after": "cursor"}`
32
+
33
+ ### Subreddit (no auth, join/leave require auth)
34
+
35
+ ```bash
36
+ cli-web-reddit sub hot <name> [--limit N] --json
37
+ cli-web-reddit sub new <name> [--limit N] --json
38
+ cli-web-reddit sub top <name> [--time day|week|month|year|all] --json
39
+ cli-web-reddit sub info <name> --json
40
+ cli-web-reddit sub rules <name> --json
41
+ cli-web-reddit sub search <name> <query> [--sort relevance|hot|top|new] --json
42
+ cli-web-reddit sub join <name> --json # requires auth
43
+ cli-web-reddit sub leave <name> --json # requires auth
44
+ ```
45
+
46
+ ### Search (no auth)
47
+
48
+ ```bash
49
+ cli-web-reddit search posts <query> [--sort relevance|hot|top|new] [--time hour|day|week] --json
50
+ cli-web-reddit search subs <query> [--limit N] --json
51
+ ```
52
+
53
+ ### User (no auth)
54
+
55
+ ```bash
56
+ cli-web-reddit user info <username> --json
57
+ cli-web-reddit user posts <username> [--sort hot|new|top] [--limit N] --json
58
+ cli-web-reddit user comments <username> [--sort hot|new|top] [--limit N] --json
59
+ ```
60
+
61
+ ### Post Detail (no auth)
62
+
63
+ ```bash
64
+ cli-web-reddit post get <url_or_id> [--sub <name>] [--comments N] --json
65
+ ```
66
+
67
+ Fetches all comments including deeply nested threads (automatically expands
68
+ "continue this thread" and collapsed comment chains via Reddit's morechildren API).
69
+ Accepts full URLs, short IDs, or `t3_` prefixed fullnames.
70
+
71
+ ### Auth
72
+
73
+ ```bash
74
+ cli-web-reddit auth login # opens browser, extracts token_v2
75
+ cli-web-reddit auth status --json
76
+ cli-web-reddit auth logout
77
+ ```
78
+
79
+ ### Vote (requires auth)
80
+
81
+ ```bash
82
+ cli-web-reddit vote up <thing_id> --json # t3_xxx or t1_xxx
83
+ cli-web-reddit vote down <thing_id> --json
84
+ cli-web-reddit vote unvote <thing_id> --json
85
+ ```
86
+
87
+ ### Submit (requires auth)
88
+
89
+ ```bash
90
+ cli-web-reddit submit flairs <subreddit> --json # list available flairs
91
+ cli-web-reddit submit text <subreddit> <title> <body> [--flair ID] --json
92
+ cli-web-reddit submit link <subreddit> <title> <url> [--flair ID] --json
93
+ ```
94
+
95
+ Use `submit flairs` first to get flair IDs when a subreddit requires flair.
96
+
97
+ ### Comment (requires auth)
98
+
99
+ ```bash
100
+ cli-web-reddit comment add <thing_id> <text> --json
101
+ cli-web-reddit comment edit <thing_id> <text> --json
102
+ cli-web-reddit comment delete <thing_id> --json
103
+ ```
104
+
105
+ ### Save (requires auth)
106
+
107
+ ```bash
108
+ cli-web-reddit saved save <thing_id> --json
109
+ cli-web-reddit saved unsave <thing_id> --json
110
+ ```
111
+
112
+ ### Me (requires auth)
113
+
114
+ ```bash
115
+ cli-web-reddit me profile --json
116
+ cli-web-reddit me saved [--limit N] --json
117
+ cli-web-reddit me upvoted [--limit N] --json
118
+ cli-web-reddit me subscriptions --json
119
+ cli-web-reddit me inbox [--limit N] --json
120
+ ```
121
+
122
+ ## Agent Patterns
123
+
124
+ ```bash
125
+ # Get trending posts from a subreddit
126
+ cli-web-reddit sub top python --time week --limit 10 --json | jq '.posts[] | {title, score, url}'
127
+
128
+ # Search and get post details
129
+ cli-web-reddit search posts "fastapi tutorial" --limit 3 --json
130
+
131
+ # Check a user's activity
132
+ cli-web-reddit user posts spez --limit 5 --json
133
+ ```
134
+
135
+ ## Notes
136
+
137
+ - Public read commands work without auth (uses Reddit's .json API with curl_cffi)
138
+ - Write operations (vote, comment, submit, save) require `auth login` first
139
+ - Auth uses `token_v2` cookie extracted via Playwright browser login
140
+ - **Token auto-refresh**: when `token_v2` expires (~15-30 min), the CLI silently launches a headless browser with the saved profile to refresh it — no manual re-login needed
141
+ - HTTP 403 on specific endpoints (e.g., flair fetch on some subreddits) is correctly reported as "Permission denied" rather than "AUTH_EXPIRED"
142
+ - Rate limits: ~60 requests/minute for public API, ~600/minute with OAuth
143
+ - All commands support `--json` for structured output
@@ -0,0 +1,109 @@
1
+ # TEST.md — cli-web-reddit Test Plan & Results
2
+
3
+ ## Part 1: Test Plan
4
+
5
+ ### Test Inventory
6
+
7
+ | File | Tests | Layer |
8
+ |------|-------|-------|
9
+ | `test_core.py` | 47 | Unit (mocked HTTP) + Click integration |
10
+ | `test_e2e.py` | 19 | Live API + subprocess |
11
+ | **Total** | **66** | |
12
+
13
+ ### Unit Tests (test_core.py)
14
+
15
+ **TestExceptionHierarchy (9 tests)**
16
+ - All exceptions inherit from RedditError
17
+ - RateLimitError has retry_after (defaults to None)
18
+ - ServerError has status_code (defaults to 500)
19
+
20
+ **TestClientErrorMapping (8 tests)**
21
+ - 404 → NotFoundError
22
+ - 429 → RateLimitError (with and without retry-after header)
23
+ - 500/503 → ServerError with status_code
24
+ - Connection error → NetworkError
25
+ - Successful JSON response parsing
26
+ - Generic 4xx → RedditError
27
+
28
+ **TestModels (9 tests)**
29
+ - format_post_summary: extracts fields, handles missing data
30
+ - format_subreddit_info: subscribers, name, type
31
+ - format_user_info: karma, verification
32
+ - format_comment: body, depth, is_submitter
33
+ - extract_listing_posts: pagination cursor, filters non-t3 children, empty listings
34
+
35
+ **TestHelpers (14 tests)**
36
+ - json_error format with extra fields
37
+ - truncate: short, long, None, empty string
38
+ - handle_errors exit codes: NotFoundError→1, ServerError→2, NetworkError→2, RateLimitError→1, KeyboardInterrupt→130
39
+ - JSON mode error output for each exception type
40
+
41
+ **TestCLIClick (7 tests)**
42
+ - --version flag
43
+ - --help shows all command groups
44
+ - feed hot --json with mocked client
45
+ - feed new --json with mocked client
46
+ - search posts --json with mocked client
47
+ - JSON error output on NotFoundError
48
+ - JSON error output on NetworkError
49
+
50
+ ### E2E Tests (test_e2e.py)
51
+
52
+ **TestFeedLive (4 tests)**
53
+ - feed hot: verify posts with required fields (id, title, author, score)
54
+ - feed top: time filter works (day)
55
+ - feed popular: returns posts from r/popular
56
+ - pagination: cursor-based pagination returns different posts
57
+
58
+ **TestSubredditLive (4 tests)**
59
+ - sub posts: fetch r/python posts
60
+ - sub info: verify name, subscribers, type fields
61
+ - sub rules: verify rules list returned
62
+ - list-get roundtrip: list posts → get one by ID → verify fields match
63
+
64
+ **TestSearchLive (2 tests)**
65
+ - search posts: keyword search returns results
66
+ - search subreddits: subreddit search returns results with names
67
+
68
+ **TestUserLive (2 tests)**
69
+ - user about: verify u/spez profile fields
70
+ - user posts: verify user submissions returned
71
+
72
+ **TestCLISubprocess (7 tests)**
73
+ - --help: shows all command groups
74
+ - --version: shows 0.1.0
75
+ - feed hot --json: valid JSON with posts array
76
+ - search posts --json: search results with query
77
+ - sub info --json: subreddit details
78
+ - user info --json: user profile
79
+ - Human-readable table output (non-JSON)
80
+
81
+ Subprocess tests use `_resolve_cli("cli-web-reddit")` pattern with Windows UTF-8 encoding.
82
+
83
+ ---
84
+
85
+ ## Part 2: Test Results
86
+
87
+ **Date:** 2026-03-24
88
+ **Environment:** Windows 11, Python 3.12.8, curl_cffi
89
+
90
+ ### Unit Tests
91
+ ```
92
+ 47 passed in 0.57s
93
+ ```
94
+
95
+ ### E2E + Subprocess Tests
96
+ ```
97
+ 19 passed in 19.14s
98
+ ```
99
+
100
+ ### Summary
101
+
102
+ | Metric | Value |
103
+ |--------|-------|
104
+ | Total tests | 66 |
105
+ | Passed | 66 |
106
+ | Failed | 0 |
107
+ | Pass rate | 100% |
108
+ | Unit test time | 0.57s |
109
+ | E2E test time | 19.14s |
File without changes
@@ -0,0 +1,9 @@
1
+ """Pytest configuration — register custom markers."""
2
+
3
+
4
+ def pytest_configure(config):
5
+ config.addinivalue_line("markers", "unit: Fast unit tests (mocked HTTP, no network)")
6
+ config.addinivalue_line("markers", "live: Live API tests that hit the real Reddit API")
7
+ config.addinivalue_line(
8
+ "markers", "subprocess: Subprocess tests that invoke the installed CLI binary"
9
+ )