rdt-cli 0.2.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,386 @@
1
+ """Browse commands: feed, subreddit, popular, all, user, open."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ import click
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ from ..client import RedditClient
12
+ from ..constants import SORT_OPTIONS, TIME_FILTERS
13
+ from ..index_cache import save_index
14
+ from ._common import (
15
+ compact_posts,
16
+ console,
17
+ format_score,
18
+ format_time,
19
+ handle_command,
20
+ listing_options,
21
+ maybe_print_structured,
22
+ open_url,
23
+ optional_auth,
24
+ require_auth,
25
+ save_output_to_file,
26
+ structured_output_options,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Default title truncation length
32
+ _TITLE_MAX = 50
33
+ _FULL_TITLE_MAX = 200
34
+
35
+
36
+ # ── Helpers ─────────────────────────────────────────────────────────
37
+
38
+
39
+ def _render_post_table(
40
+ posts: list[dict], title: str,
41
+ show_subreddit: bool = True, full_text: bool = False,
42
+ ) -> None:
43
+ """Render a list of posts as a Rich table."""
44
+ if not posts:
45
+ console.print("[yellow]No posts found[/yellow]")
46
+ return
47
+
48
+ save_index(posts, source=title[:40])
49
+ max_title = _FULL_TITLE_MAX if full_text else _TITLE_MAX
50
+
51
+ table = Table(title=title, show_lines=True)
52
+ table.add_column("#", style="dim", width=3)
53
+ table.add_column("Score", style="yellow", width=6, justify="right")
54
+ if show_subreddit:
55
+ table.add_column("Subreddit", style="magenta", max_width=15)
56
+ table.add_column(
57
+ "Title", style="bold cyan",
58
+ max_width=max_title if not full_text else None,
59
+ )
60
+ table.add_column("Author", style="green", max_width=14)
61
+ table.add_column("💬", style="dim", width=5, justify="right")
62
+ table.add_column("Time", style="dim", max_width=10)
63
+
64
+ for i, post in enumerate(posts, 1):
65
+ title_text = post.get("title", "-")
66
+ if post.get("stickied"):
67
+ title_text = f"📌 {title_text}"
68
+ if post.get("over_18"):
69
+ title_text = f"🔞 {title_text}"
70
+ if post.get("is_video"):
71
+ title_text = f"🎬 {title_text}"
72
+
73
+ if not full_text:
74
+ title_text = title_text[:max_title]
75
+
76
+ row = [
77
+ str(i),
78
+ format_score(post.get("score", 0)),
79
+ ]
80
+ if show_subreddit:
81
+ row.append(f"r/{post.get('subreddit', '?')}")
82
+ row.extend([
83
+ title_text,
84
+ post.get("author", "-")[:14],
85
+ str(post.get("num_comments", 0)),
86
+ format_time(post.get("created_utc", 0)),
87
+ ])
88
+ table.add_row(*row)
89
+
90
+ console.print(table)
91
+ console.print("\n [dim]💡 Use [bold]rdt show <#>[/bold] to read a post[/dim]")
92
+
93
+
94
+ def _listing_render(
95
+ data: dict, title: str,
96
+ show_subreddit: bool = True, next_cmd: str = "",
97
+ full_text: bool = False,
98
+ ) -> None:
99
+ """Common render for listing endpoints."""
100
+ posts = RedditClient._extract_posts(data)
101
+ _render_post_table(posts, title, show_subreddit=show_subreddit, full_text=full_text)
102
+ cursor = RedditClient._extract_after(data)
103
+ if cursor and next_cmd:
104
+ console.print(f" [dim]▸ More: {next_cmd} --after {cursor}[/dim]")
105
+
106
+
107
+ def _handle_listing(
108
+ cred, *, action, data_title: str, next_cmd: str = "",
109
+ show_subreddit: bool = True,
110
+ as_json: bool, as_yaml: bool,
111
+ output_file: str | None = None,
112
+ full_text: bool = False,
113
+ compact: bool = False,
114
+ ) -> None:
115
+ """Unified listing handler with --output/--full-text/--compact support."""
116
+ from ..exceptions import RedditApiError
117
+ from ._common import exit_for_error, run_client_action
118
+
119
+ try:
120
+ data = run_client_action(cred, action)
121
+
122
+ # --output: save to file
123
+ if output_file:
124
+ out_data = data
125
+ if compact:
126
+ posts = RedditClient._extract_posts(data)
127
+ out_data = compact_posts(posts)
128
+ save_output_to_file(out_data, output_file)
129
+ return
130
+
131
+ # --compact: strip fields for structured output
132
+ if compact and (as_json or as_yaml):
133
+ posts = RedditClient._extract_posts(data)
134
+ data = compact_posts(posts)
135
+
136
+ # --json/--yaml: structured output
137
+ if maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml):
138
+ return
139
+
140
+ # Rich render
141
+ _listing_render(
142
+ data, data_title,
143
+ show_subreddit=show_subreddit,
144
+ next_cmd=next_cmd,
145
+ full_text=full_text,
146
+ )
147
+ except RedditApiError as exc:
148
+ exit_for_error(exc, as_json=as_json, as_yaml=as_yaml)
149
+
150
+
151
+ # ── feed ────────────────────────────────────────────────────────────
152
+
153
+
154
+ @click.command()
155
+ @click.option("-n", "--limit", default=25, type=int, help="Number of posts (default: 25)")
156
+ @click.option("--after", default=None, help="Pagination cursor")
157
+ @listing_options
158
+ def feed(
159
+ limit: int, after: str | None,
160
+ as_json: bool, as_yaml: bool,
161
+ output_file: str | None, full_text: bool, compact: bool,
162
+ ) -> None:
163
+ """Browse your home feed (requires login)"""
164
+ cred = require_auth()
165
+ _handle_listing(
166
+ cred,
167
+ action=lambda c: c.get_home(limit=limit, after=after),
168
+ data_title="🏠 Home Feed",
169
+ next_cmd="rdt feed",
170
+ as_json=as_json, as_yaml=as_yaml,
171
+ output_file=output_file, full_text=full_text, compact=compact,
172
+ )
173
+
174
+
175
+ # ── popular ─────────────────────────────────────────────────────────
176
+
177
+
178
+ @click.command()
179
+ @click.option("-n", "--limit", default=25, type=int, help="Number of posts")
180
+ @click.option("--after", default=None, help="Pagination cursor")
181
+ @listing_options
182
+ def popular(
183
+ limit: int, after: str | None,
184
+ as_json: bool, as_yaml: bool,
185
+ output_file: str | None, full_text: bool, compact: bool,
186
+ ) -> None:
187
+ """Browse /r/popular"""
188
+ cred = optional_auth()
189
+ _handle_listing(
190
+ cred,
191
+ action=lambda c: c.get_popular(limit=limit, after=after),
192
+ data_title="🔥 Popular",
193
+ next_cmd="rdt popular",
194
+ as_json=as_json, as_yaml=as_yaml,
195
+ output_file=output_file, full_text=full_text, compact=compact,
196
+ )
197
+
198
+
199
+ # ── all ─────────────────────────────────────────────────────────────
200
+
201
+
202
+ @click.command(name="all")
203
+ @click.option("-n", "--limit", default=25, type=int, help="Number of posts")
204
+ @click.option("--after", default=None, help="Pagination cursor")
205
+ @listing_options
206
+ def all_cmd(
207
+ limit: int, after: str | None,
208
+ as_json: bool, as_yaml: bool,
209
+ output_file: str | None, full_text: bool, compact: bool,
210
+ ) -> None:
211
+ """Browse /r/all"""
212
+ cred = optional_auth()
213
+ _handle_listing(
214
+ cred,
215
+ action=lambda c: c.get_all(limit=limit, after=after),
216
+ data_title="🌍 r/all",
217
+ next_cmd="rdt all",
218
+ as_json=as_json, as_yaml=as_yaml,
219
+ output_file=output_file, full_text=full_text, compact=compact,
220
+ )
221
+
222
+
223
+ # ── sub (subreddit) ─────────────────────────────────────────────────
224
+
225
+
226
+ @click.command()
227
+ @click.argument("subreddit")
228
+ @click.option("-s", "--sort", type=click.Choice(SORT_OPTIONS), default="hot", help="Sort order")
229
+ @click.option(
230
+ "-t", "--time", "time_filter",
231
+ type=click.Choice(TIME_FILTERS), default=None,
232
+ help="Time filter (for top/controversial)",
233
+ )
234
+ @click.option("-n", "--limit", default=25, type=int, help="Number of posts")
235
+ @click.option("--after", default=None, help="Pagination cursor")
236
+ @listing_options
237
+ def sub(
238
+ subreddit: str, sort: str, time_filter: str | None, limit: int,
239
+ after: str | None, as_json: bool, as_yaml: bool,
240
+ output_file: str | None, full_text: bool, compact: bool,
241
+ ) -> None:
242
+ """Browse a subreddit (e.g., rdt sub python)"""
243
+ cred = optional_auth()
244
+ emoji = {"hot": "🔥", "new": "🆕", "top": "🏆", "rising": "📈"}.get(sort, "📋")
245
+ _handle_listing(
246
+ cred,
247
+ action=lambda c: c.get_subreddit(
248
+ subreddit, sort=sort, limit=limit, after=after, time_filter=time_filter,
249
+ ),
250
+ data_title=f"{emoji} r/{subreddit} ({sort})",
251
+ show_subreddit=False,
252
+ next_cmd=f"rdt sub {subreddit} -s {sort}",
253
+ as_json=as_json, as_yaml=as_yaml,
254
+ output_file=output_file, full_text=full_text, compact=compact,
255
+ )
256
+
257
+
258
+ # ── sub-info ────────────────────────────────────────────────────────
259
+
260
+
261
+ @click.command("sub-info")
262
+ @click.argument("subreddit")
263
+ @structured_output_options
264
+ def sub_info(subreddit: str, as_json: bool, as_yaml: bool) -> None:
265
+ """View subreddit info (subscribers, description)"""
266
+ cred = optional_auth()
267
+
268
+ def _render(data: dict) -> None:
269
+ name = data.get("display_name_prefixed", f"r/{subreddit}")
270
+ desc = data.get("public_description", data.get("description", ""))
271
+ subs = data.get("subscribers", 0)
272
+ active = data.get("accounts_active", 0)
273
+ created = data.get("created_utc", 0)
274
+ nsfw = "🔞 NSFW" if data.get("over18") else ""
275
+
276
+ text = (
277
+ f"[bold cyan]{name}[/bold cyan] {nsfw}\n"
278
+ f"👥 {subs:,} subscribers · 🟢 {active:,} online\n"
279
+ f"📅 Created: {format_time(created)}\n"
280
+ )
281
+ if desc:
282
+ text += f"\n{desc[:300]}"
283
+
284
+ panel = Panel(text, title=f"📋 {name}", border_style="cyan")
285
+ console.print(panel)
286
+
287
+ handle_command(
288
+ cred,
289
+ action=lambda c: c.get_subreddit_about(subreddit),
290
+ render=_render, as_json=as_json, as_yaml=as_yaml,
291
+ )
292
+
293
+
294
+ # ── user ────────────────────────────────────────────────────────────
295
+
296
+
297
+ @click.command()
298
+ @click.argument("username")
299
+ @structured_output_options
300
+ def user(username: str, as_json: bool, as_yaml: bool) -> None:
301
+ """View a user's profile"""
302
+ cred = optional_auth()
303
+
304
+ def _render(data: dict) -> None:
305
+ name = data.get("name", username)
306
+ karma_post = data.get("link_karma", 0)
307
+ karma_comment = data.get("comment_karma", 0)
308
+ created = data.get("created_utc", 0)
309
+ is_gold = "⭐ " if data.get("is_gold") else ""
310
+
311
+ text = (
312
+ f"[bold cyan]u/{name}[/bold cyan] {is_gold}\n"
313
+ f"📊 Post karma: {karma_post:,} · Comment karma: {karma_comment:,}\n"
314
+ f"📅 Account age: {format_time(created)}\n"
315
+ )
316
+
317
+ panel = Panel(text, title=f"👤 u/{name}", border_style="green")
318
+ console.print(panel)
319
+
320
+ handle_command(
321
+ cred, action=lambda c: c.get_user_about(username),
322
+ render=_render, as_json=as_json, as_yaml=as_yaml,
323
+ )
324
+
325
+
326
+ # ── user-posts ──────────────────────────────────────────────────────
327
+
328
+
329
+ @click.command("user-posts")
330
+ @click.argument("username")
331
+ @click.option("-n", "--limit", default=25, type=int, help="Number of posts")
332
+ @click.option("--after", default=None, help="Pagination cursor")
333
+ @listing_options
334
+ def user_posts(
335
+ username: str, limit: int, after: str | None,
336
+ as_json: bool, as_yaml: bool,
337
+ output_file: str | None, full_text: bool, compact: bool,
338
+ ) -> None:
339
+ """View a user's submitted posts"""
340
+ cred = optional_auth()
341
+ _handle_listing(
342
+ cred,
343
+ action=lambda c: c.get_user_posts(username, limit=limit, after=after),
344
+ data_title=f"📝 u/{username}'s posts",
345
+ as_json=as_json, as_yaml=as_yaml,
346
+ output_file=output_file, full_text=full_text, compact=compact,
347
+ )
348
+
349
+
350
+ # ── open ────────────────────────────────────────────────────────────
351
+
352
+
353
+ @click.command(name="open")
354
+ @click.argument("id_or_index")
355
+ def open_post(id_or_index: str) -> None:
356
+ """Open a post in the browser (by ID or index number)
357
+
358
+ Examples:
359
+ rdt open 3 # open result #3 in browser
360
+ rdt open 1abc123 # open by post ID
361
+ """
362
+ from ..index_cache import get_item_by_index
363
+
364
+ # Try as short-index
365
+ try:
366
+ idx = int(id_or_index)
367
+ item = get_item_by_index(idx)
368
+ if item:
369
+ permalink = item.get("permalink", "")
370
+ if permalink:
371
+ url = f"https://reddit.com{permalink}"
372
+ console.print(f"[dim]Opening: {url}[/dim]")
373
+ open_url(url)
374
+ return
375
+ console.print(f"[yellow]Index {idx} not found in cache[/yellow]")
376
+ return
377
+ except ValueError:
378
+ pass
379
+
380
+ # Bare ID or URL
381
+ if id_or_index.startswith("http"):
382
+ open_url(id_or_index)
383
+ else:
384
+ url = f"https://reddit.com/comments/{id_or_index}"
385
+ console.print(f"[dim]Opening: {url}[/dim]")
386
+ open_url(url)
@@ -0,0 +1,183 @@
1
+ """Post commands: read, show, comments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ import click
8
+ from rich.panel import Panel
9
+
10
+ from ..index_cache import get_index_info, get_item_by_index
11
+ from ._common import (
12
+ console,
13
+ handle_command,
14
+ optional_auth,
15
+ structured_output_options,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # ── Helpers ─────────────────────────────────────────────────────────
22
+
23
+
24
+ def _render_post_detail(data: list | dict) -> None:
25
+ """Render a post with its comments."""
26
+ if isinstance(data, list) and len(data) >= 1:
27
+ # [post_listing, comments_listing]
28
+ post_listing = data[0]
29
+ post_children = post_listing.get("data", {}).get("children", [])
30
+ post = post_children[0].get("data", {}) if post_children else {}
31
+
32
+ comments_listing = data[1] if len(data) > 1 else {}
33
+ comment_children = comments_listing.get("data", {}).get("children", [])
34
+ else:
35
+ post = data if isinstance(data, dict) else {}
36
+ comment_children = []
37
+
38
+ # Render post
39
+ title = post.get("title", "Untitled")
40
+ author = post.get("author", "?")
41
+ subreddit = post.get("subreddit", "?")
42
+ score = post.get("score", 0)
43
+ num_comments = post.get("num_comments", 0)
44
+ selftext = post.get("selftext", "")
45
+ url = post.get("url", "")
46
+ is_self = post.get("is_self", True)
47
+ permalink = post.get("permalink", "")
48
+
49
+ post_text = (
50
+ f"[bold cyan]{title}[/bold cyan]\n"
51
+ f"[dim]r/{subreddit}[/dim] · [green]u/{author}[/green] · "
52
+ f"[yellow]⬆ {score}[/yellow] · 💬 {num_comments}\n"
53
+ )
54
+
55
+ if not is_self and url:
56
+ post_text += f"\n🔗 {url}\n"
57
+
58
+ if selftext:
59
+ # Truncate very long posts
60
+ if len(selftext) > 1500:
61
+ selftext = selftext[:1500] + "\n\n... [truncated]"
62
+ post_text += f"\n{selftext}"
63
+
64
+ if permalink:
65
+ post_text += f"\n\n[dim]https://reddit.com{permalink}[/dim]"
66
+
67
+ panel = Panel(post_text, title="📰 Post", border_style="cyan")
68
+ console.print(panel)
69
+
70
+ # Render comments
71
+ if comment_children:
72
+ console.print()
73
+ _render_comments(comment_children, depth=0, max_depth=3)
74
+
75
+
76
+ def _render_comments(children: list[dict], depth: int = 0, max_depth: int = 3) -> None:
77
+ """Recursively render comment tree."""
78
+ for child in children:
79
+ if child.get("kind") != "t1":
80
+ continue
81
+ comment = child.get("data", {})
82
+ author = comment.get("author", "[deleted]")
83
+ body = comment.get("body", "")
84
+ score = comment.get("score", 0)
85
+
86
+ indent = " " * depth
87
+ score_color = "yellow" if score > 0 else "red" if score < 0 else "dim"
88
+
89
+ # Truncate long comments
90
+ if len(body) > 300:
91
+ body = body[:300] + "..."
92
+
93
+ console.print(
94
+ f"{indent}[green]u/{author}[/green] [{score_color}]⬆ {score}[/{score_color}]"
95
+ )
96
+ for line in body.split("\n"):
97
+ console.print(f"{indent} {line}")
98
+ console.print()
99
+
100
+ # Render replies
101
+ if depth < max_depth:
102
+ replies = comment.get("replies", "")
103
+ if isinstance(replies, dict):
104
+ reply_children = replies.get("data", {}).get("children", [])
105
+ if reply_children:
106
+ _render_comments(reply_children, depth=depth + 1, max_depth=max_depth)
107
+
108
+
109
+ # ── read ────────────────────────────────────────────────────────────
110
+
111
+
112
+ @click.command()
113
+ @click.argument("post_id")
114
+ @click.option(
115
+ "-s", "--sort", default="best",
116
+ type=click.Choice(["best", "top", "new", "controversial", "old", "qa"]),
117
+ help="Comment sort",
118
+ )
119
+ @click.option("-n", "--limit", default=25, type=int, help="Number of comments")
120
+ @structured_output_options
121
+ def read(post_id: str, sort: str, limit: int, as_json: bool, as_yaml: bool) -> None:
122
+ """Read a post and its comments by ID
123
+
124
+ Example: rdt read 1abc123
125
+ """
126
+ cred = optional_auth()
127
+ handle_command(
128
+ cred,
129
+ action=lambda c: c.get_post_comments(post_id=post_id, sort=sort, limit=limit),
130
+ render=_render_post_detail,
131
+ as_json=as_json,
132
+ as_yaml=as_yaml,
133
+ )
134
+
135
+
136
+ # ── show (short-index) ──────────────────────────────────────────────
137
+
138
+
139
+ @click.command()
140
+ @click.argument("index", type=int)
141
+ @click.option(
142
+ "-s", "--sort", default="best",
143
+ type=click.Choice(["best", "top", "new", "controversial", "old", "qa"]),
144
+ help="Comment sort",
145
+ )
146
+ @click.option("-n", "--limit", default=25, type=int, help="Number of comments")
147
+ @structured_output_options
148
+ def show(index: int, sort: str, limit: int, as_json: bool, as_yaml: bool) -> None:
149
+ """Read a post by its index from last listing (e.g., rdt show 3)
150
+
151
+ Use after rdt feed, rdt sub, rdt search, etc.
152
+ """
153
+ item = get_item_by_index(index)
154
+ if not item:
155
+ info = get_index_info()
156
+ if not info.get("exists"):
157
+ console.print("[yellow]No cached results. Run rdt feed, rdt sub, or rdt search first.[/yellow]")
158
+ else:
159
+ console.print(f"[yellow]Index {index} out of range (total: {info.get('count', 0)})[/yellow]")
160
+ return
161
+
162
+ post_id = item.get("id", "")
163
+ if not post_id:
164
+ console.print("[red]❌ Cached item has no post ID[/red]")
165
+ return
166
+
167
+ # Show brief info from cache
168
+ console.print(
169
+ f" [dim]#{index}[/dim] [cyan]{item.get('title', '-')[:60]}[/cyan] "
170
+ f"[dim]r/{item.get('subreddit', '?')}[/dim] "
171
+ f"[yellow]⬆ {item.get('score', 0)}[/yellow]"
172
+ )
173
+ console.print()
174
+
175
+ # Fetch full post + comments
176
+ cred = optional_auth()
177
+ handle_command(
178
+ cred,
179
+ action=lambda c: c.get_post_comments(post_id=post_id, sort=sort, limit=limit),
180
+ render=_render_post_detail,
181
+ as_json=as_json,
182
+ as_yaml=as_yaml,
183
+ )