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,227 @@
1
+ """Search and export commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ import logging
9
+ import sys
10
+
11
+ import click
12
+ from rich.table import Table
13
+
14
+ from ..client import RedditClient
15
+ from ..constants import SEARCH_SORT_OPTIONS, TIME_FILTERS
16
+ from ..exceptions import RedditApiError
17
+ from ..index_cache import save_index
18
+ from ._common import (
19
+ compact_posts,
20
+ console,
21
+ exit_for_error,
22
+ format_score,
23
+ listing_options,
24
+ maybe_print_structured,
25
+ optional_auth,
26
+ save_output_to_file,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def _render_search_table(
33
+ posts: list[dict], query: str, full_text: bool = False,
34
+ ) -> None:
35
+ """Render search results as a Rich table."""
36
+ if not posts:
37
+ console.print(f"[yellow]No results for '{query}'[/yellow]")
38
+ return
39
+
40
+ save_index(posts, source=f"search:{query}")
41
+ max_title = 200 if full_text else 45
42
+
43
+ table = Table(title=f'🔍 Search: "{query}" — {len(posts)} results', show_lines=True)
44
+ table.add_column("#", style="dim", width=3)
45
+ table.add_column("Score", style="yellow", width=6, justify="right")
46
+ table.add_column("Subreddit", style="magenta", max_width=15)
47
+ table.add_column(
48
+ "Title", style="bold cyan",
49
+ max_width=max_title if not full_text else None,
50
+ )
51
+ table.add_column("Author", style="green", max_width=12)
52
+ table.add_column("💬", style="dim", width=5, justify="right")
53
+
54
+ for i, post in enumerate(posts, 1):
55
+ title_text = post.get("title", "-")
56
+ if not full_text:
57
+ title_text = title_text[:max_title]
58
+ table.add_row(
59
+ str(i),
60
+ format_score(post.get("score", 0)),
61
+ f"r/{post.get('subreddit', '?')}",
62
+ title_text,
63
+ post.get("author", "-")[:12],
64
+ str(post.get("num_comments", 0)),
65
+ )
66
+
67
+ console.print(table)
68
+ console.print("\n [dim]💡 Use [bold]rdt show <#>[/bold] to read a result[/dim]")
69
+
70
+
71
+ # ── search ──────────────────────────────────────────────────────────
72
+
73
+
74
+ @click.command()
75
+ @click.argument("query")
76
+ @click.option("-r", "--subreddit", default=None, help="Search within subreddit")
77
+ @click.option(
78
+ "-s", "--sort", type=click.Choice(SEARCH_SORT_OPTIONS),
79
+ default="relevance", help="Sort order",
80
+ )
81
+ @click.option(
82
+ "-t", "--time", "time_filter",
83
+ type=click.Choice(TIME_FILTERS), default="all", help="Time filter",
84
+ )
85
+ @click.option("-n", "--limit", default=25, type=int, help="Number of results")
86
+ @click.option("--after", default=None, help="Pagination cursor")
87
+ @listing_options
88
+ def search(
89
+ query: str,
90
+ subreddit: str | None,
91
+ sort: str,
92
+ time_filter: str,
93
+ limit: int,
94
+ after: str | None,
95
+ as_json: bool,
96
+ as_yaml: bool,
97
+ output_file: str | None,
98
+ full_text: bool,
99
+ compact: bool,
100
+ ) -> None:
101
+ """Search Reddit posts
102
+
103
+ Examples:
104
+ rdt search "python async"
105
+ rdt search "rust vs go" -r programming --sort top --time year
106
+ """
107
+ cred = optional_auth()
108
+
109
+ try:
110
+ with RedditClient(cred) as client:
111
+ data = client.search(
112
+ query=query,
113
+ subreddit=subreddit,
114
+ sort=sort,
115
+ time_filter=time_filter,
116
+ limit=limit,
117
+ after=after,
118
+ )
119
+
120
+ posts = RedditClient._extract_posts(data)
121
+ if posts:
122
+ save_index(posts, source=f"search:{query}")
123
+
124
+ # --output: save to file
125
+ if output_file:
126
+ out_data = compact_posts(posts) if compact else data
127
+ save_output_to_file(out_data, output_file)
128
+ return
129
+
130
+ # --compact: strip fields for structured output
131
+ out_data = data
132
+ if compact and (as_json or as_yaml):
133
+ out_data = compact_posts(posts)
134
+
135
+ if maybe_print_structured(out_data, as_json=as_json, as_yaml=as_yaml):
136
+ # Show pagination hint
137
+ cursor = RedditClient._extract_after(data)
138
+ if cursor:
139
+ console.print(
140
+ f' [dim]▸ More: rdt search "{query}" --after {cursor}[/dim]',
141
+ )
142
+ return
143
+
144
+ _render_search_table(posts, query, full_text=full_text)
145
+
146
+ # Show pagination hint
147
+ cursor = RedditClient._extract_after(data)
148
+ if cursor and sys.stdout.isatty():
149
+ console.print(f' [dim]▸ More: rdt search "{query}" --after {cursor}[/dim]')
150
+
151
+ except RedditApiError as exc:
152
+ exit_for_error(exc, as_json=as_json, as_yaml=as_yaml, prefix="Search failed")
153
+
154
+
155
+ # ── export ──────────────────────────────────────────────────────────
156
+
157
+
158
+ @click.command()
159
+ @click.argument("query")
160
+ @click.option("-r", "--subreddit", default=None, help="Search within subreddit")
161
+ @click.option("-s", "--sort", type=click.Choice(SEARCH_SORT_OPTIONS), default="relevance", help="Sort order")
162
+ @click.option("-n", "--count", default=50, type=int, help="Number of results to export")
163
+ @click.option("-o", "--output", "output_file", default=None, help="Output file path")
164
+ @click.option("--format", "fmt", type=click.Choice(["csv", "json"]), default="csv", help="Output format")
165
+ def export(query: str, subreddit: str | None, sort: str, count: int, output_file: str | None, fmt: str) -> None:
166
+ """Export search results to CSV or JSON
167
+
168
+ Examples:
169
+ rdt export "machine learning" -n 100 -o results.csv
170
+ rdt export "python tips" --format json -o tips.json
171
+ """
172
+ cred = optional_auth()
173
+ all_posts: list[dict] = []
174
+ after = None
175
+
176
+ try:
177
+ with RedditClient(cred) as client:
178
+ pages = 0
179
+ max_pages = (count + 24) // 25
180
+
181
+ while len(all_posts) < count and pages < max_pages:
182
+ data = client.search(query=query, subreddit=subreddit, sort=sort, limit=25, after=after)
183
+ posts = RedditClient._extract_posts(data)
184
+ if not posts:
185
+ break
186
+ all_posts.extend(posts)
187
+ after = RedditClient._extract_after(data)
188
+ if not after:
189
+ break
190
+ pages += 1
191
+
192
+ all_posts = all_posts[:count]
193
+
194
+ if not all_posts:
195
+ console.print(f"[yellow]No results found for '{query}'[/yellow]")
196
+ return
197
+
198
+ if fmt == "json":
199
+ text = json.dumps(all_posts, indent=2, ensure_ascii=False)
200
+ else:
201
+ buf = io.StringIO()
202
+ fieldnames = ["title", "subreddit", "author", "score", "num_comments", "url", "permalink"]
203
+ writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
204
+ writer.writeheader()
205
+ for p in all_posts:
206
+ row = {
207
+ "title": p.get("title", ""),
208
+ "subreddit": p.get("subreddit", ""),
209
+ "author": p.get("author", ""),
210
+ "score": p.get("score", 0),
211
+ "num_comments": p.get("num_comments", 0),
212
+ "url": p.get("url", ""),
213
+ "permalink": f"https://reddit.com{p.get('permalink', '')}",
214
+ }
215
+ writer.writerow(row)
216
+ text = buf.getvalue()
217
+
218
+ if output_file:
219
+ encoding = "utf-8-sig" if fmt == "csv" else "utf-8"
220
+ with open(output_file, "w", encoding=encoding) as f:
221
+ f.write(text)
222
+ console.print(f"[green]✅ Exported {len(all_posts)} results to {output_file}[/green]")
223
+ else:
224
+ click.echo(text)
225
+
226
+ except RedditApiError as exc:
227
+ exit_for_error(exc, prefix="Export failed")
@@ -0,0 +1,163 @@
1
+ """Social / interaction commands: upvote, save, subscribe, comment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..client import RedditClient
8
+ from ..exceptions import RedditApiError
9
+ from ..index_cache import get_item_by_index
10
+ from ._common import console, exit_for_error, require_auth, write_delay
11
+
12
+ # ── Helpers ─────────────────────────────────────────────────────────
13
+
14
+
15
+ def _resolve_fullname(id_or_index: str) -> str | None:
16
+ """Resolve an ID or short-index to a Reddit fullname (t3_xxx).
17
+
18
+ Accepts:
19
+ - Short index (e.g., "3") → from cache
20
+ - Bare post ID (e.g., "1abc123") → prepend t3_
21
+ - Full name (e.g., "t3_1abc123") → as-is
22
+ """
23
+ # Try as short-index first
24
+ try:
25
+ idx = int(id_or_index)
26
+ item = get_item_by_index(idx)
27
+ if item:
28
+ name = item.get("name", "")
29
+ if name:
30
+ return name
31
+ pid = item.get("id", "")
32
+ if pid:
33
+ return f"t3_{pid}"
34
+ console.print(f"[yellow]Index {idx} not found in cache[/yellow]")
35
+ return None
36
+ except ValueError:
37
+ pass
38
+
39
+ # Full name
40
+ if id_or_index.startswith("t3_") or id_or_index.startswith("t1_"):
41
+ return id_or_index
42
+
43
+ # Bare ID → assume post
44
+ return f"t3_{id_or_index}"
45
+
46
+
47
+ # ── upvote ──────────────────────────────────────────────────────────
48
+
49
+
50
+ @click.command()
51
+ @click.argument("id_or_index")
52
+ @click.option("--undo", is_flag=True, help="Remove vote")
53
+ @click.option("--down", is_flag=True, help="Downvote instead")
54
+ def upvote(id_or_index: str, undo: bool, down: bool) -> None:
55
+ """Upvote a post (by ID or index number)
56
+
57
+ Examples:
58
+ rdt upvote 3 # upvote result #3
59
+ rdt upvote 1abc123 # upvote by post ID
60
+ rdt upvote 3 --down # downvote
61
+ rdt upvote 3 --undo # remove vote
62
+ """
63
+ cred = require_auth()
64
+ fullname = _resolve_fullname(id_or_index)
65
+ if not fullname:
66
+ return
67
+
68
+ direction = 0 if undo else (-1 if down else 1)
69
+ action_label = "Unvoted" if undo else ("⬇ Downvoted" if down else "⬆ Upvoted")
70
+
71
+ try:
72
+ with RedditClient(cred) as client:
73
+ client.vote(fullname, direction=direction)
74
+ write_delay()
75
+ console.print(f"[green]✅ {action_label}[/green] {fullname}")
76
+ except RedditApiError as exc:
77
+ exit_for_error(exc, prefix="Vote failed")
78
+
79
+
80
+ # ── save / unsave ──────────────────────────────────────────────────
81
+
82
+
83
+ @click.command()
84
+ @click.argument("id_or_index")
85
+ @click.option("--undo", is_flag=True, help="Unsave")
86
+ def save(id_or_index: str, undo: bool) -> None:
87
+ """Save a post (by ID or index number)
88
+
89
+ Examples:
90
+ rdt save 3 # save result #3
91
+ rdt save 3 --undo # unsave
92
+ """
93
+ cred = require_auth()
94
+ fullname = _resolve_fullname(id_or_index)
95
+ if not fullname:
96
+ return
97
+
98
+ try:
99
+ with RedditClient(cred) as client:
100
+ if undo:
101
+ client.unsave_item(fullname)
102
+ write_delay()
103
+ console.print(f"[green]✅ Unsaved[/green] {fullname}")
104
+ else:
105
+ client.save_item(fullname)
106
+ write_delay()
107
+ console.print(f"[green]✅ Saved[/green] {fullname}")
108
+ except RedditApiError as exc:
109
+ exit_for_error(exc, prefix="Save failed")
110
+
111
+
112
+ # ── subscribe / unsubscribe ────────────────────────────────────────
113
+
114
+
115
+ @click.command()
116
+ @click.argument("subreddit")
117
+ @click.option("--undo", is_flag=True, help="Unsubscribe")
118
+ def subscribe(subreddit: str, undo: bool) -> None:
119
+ """Subscribe to a subreddit
120
+
121
+ Examples:
122
+ rdt subscribe python
123
+ rdt subscribe python --undo
124
+ """
125
+ cred = require_auth()
126
+ action = "unsub" if undo else "sub"
127
+ label = "Unsubscribed from" if undo else "Subscribed to"
128
+
129
+ try:
130
+ with RedditClient(cred) as client:
131
+ client.subscribe(subreddit, action=action)
132
+ write_delay()
133
+ console.print(f"[green]✅ {label}[/green] r/{subreddit}")
134
+ except RedditApiError as exc:
135
+ exit_for_error(exc, prefix="Subscribe failed")
136
+
137
+
138
+ # ── comment ─────────────────────────────────────────────────────────
139
+
140
+
141
+ @click.command()
142
+ @click.argument("id_or_index")
143
+ @click.argument("text")
144
+ def comment(id_or_index: str, text: str) -> None:
145
+ """Post a comment on a post (by ID or index number)
146
+
147
+ Examples:
148
+ rdt comment 3 "Great post!"
149
+ rdt comment 1abc123 "Thanks for sharing"
150
+ """
151
+ cred = require_auth()
152
+ fullname = _resolve_fullname(id_or_index)
153
+ if not fullname:
154
+ return
155
+
156
+ try:
157
+ with RedditClient(cred) as client:
158
+ client.post_comment(fullname, text)
159
+ write_delay()
160
+ console.print(f"[green]✅ Comment posted[/green] on {fullname}")
161
+ except RedditApiError as exc:
162
+ exit_for_error(exc, prefix="Comment failed")
163
+
rdt_cli/constants.py ADDED
@@ -0,0 +1,83 @@
1
+ """Constants for Reddit CLI — API endpoints, headers, and config paths."""
2
+
3
+ from pathlib import Path
4
+
5
+ # ── Config ──────────────────────────────────────────────────────────
6
+ CONFIG_DIR = Path.home() / ".config" / "rdt-cli"
7
+ CREDENTIAL_FILE = CONFIG_DIR / "credential.json"
8
+
9
+ # ── Base URL ────────────────────────────────────────────────────────
10
+ BASE_URL = "https://www.reddit.com"
11
+ OAUTH_URL = "https://oauth.reddit.com"
12
+
13
+ # ── Reddit JSON API ─────────────────────────────────────────────────
14
+ # Reddit's public JSON API: append .json to any URL
15
+ # Authenticated endpoints use oauth.reddit.com
16
+
17
+ # Listing endpoints (GET, append .json)
18
+ HOME_URL = "/.json"
19
+ POPULAR_URL = "/r/popular.json"
20
+ ALL_URL = "/r/all.json"
21
+ SUBREDDIT_URL = "/r/{subreddit}.json" # hot by default
22
+ SUBREDDIT_NEW_URL = "/r/{subreddit}/new.json"
23
+ SUBREDDIT_TOP_URL = "/r/{subreddit}/top.json"
24
+ SUBREDDIT_RISING_URL = "/r/{subreddit}/rising.json"
25
+ SUBREDDIT_ABOUT_URL = "/r/{subreddit}/about.json"
26
+
27
+ # Post / comments
28
+ POST_COMMENTS_URL = "/r/{subreddit}/comments/{post_id}.json"
29
+ POST_COMMENTS_SHORT_URL = "/comments/{post_id}.json"
30
+
31
+ # Search
32
+ SEARCH_URL = "/search.json"
33
+ SUBREDDIT_SEARCH_URL = "/r/{subreddit}/search.json"
34
+
35
+ # User
36
+ USER_ABOUT_URL = "/user/{username}/about.json"
37
+ USER_POSTS_URL = "/user/{username}/submitted.json"
38
+ USER_COMMENTS_URL = "/user/{username}/comments.json"
39
+ USER_SAVED_URL = "/user/{username}/saved.json"
40
+ USER_UPVOTED_URL = "/user/{username}/upvoted.json"
41
+
42
+ # Auth / identity (OAuth)
43
+ ME_URL = "/api/v1/me"
44
+
45
+ # Write actions (OAuth, POST)
46
+ VOTE_URL = "/api/vote"
47
+ SAVE_URL = "/api/save"
48
+ UNSAVE_URL = "/api/unsave"
49
+ SUBSCRIBE_URL = "/api/subscribe"
50
+ COMMENT_URL = "/api/comment"
51
+
52
+ # ── Request Headers (Chrome 133, macOS) ─────────────────────────────
53
+ HEADERS = {
54
+ "User-Agent": (
55
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
56
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
57
+ "Chrome/133.0.0.0 Safari/537.36"
58
+ ),
59
+ "sec-ch-ua": '"Chromium";v="133", "Not(A:Brand";v="99", "Google Chrome";v="133"',
60
+ "sec-ch-ua-mobile": "?0",
61
+ "sec-ch-ua-platform": '"macOS"',
62
+ "Sec-Fetch-Dest": "empty",
63
+ "Sec-Fetch-Mode": "cors",
64
+ "Sec-Fetch-Site": "same-origin",
65
+ "Accept": "application/json, text/plain, */*",
66
+ "Accept-Language": "en-US,en;q=0.9",
67
+ }
68
+
69
+ # ── Cookie keys required for authenticated sessions ─────────────────
70
+ REQUIRED_COOKIES = {"reddit_session"}
71
+
72
+ # ── Sort options ────────────────────────────────────────────────────
73
+ SORT_OPTIONS = ["hot", "new", "top", "rising", "controversial", "best"]
74
+
75
+ # ── Time filter for top/controversial ───────────────────────────────
76
+ TIME_FILTERS = ["hour", "day", "week", "month", "year", "all"]
77
+
78
+ # ── Search sort options ─────────────────────────────────────────────
79
+ SEARCH_SORT_OPTIONS = ["relevance", "hot", "top", "new", "comments"]
80
+
81
+ # ── Default page size ───────────────────────────────────────────────
82
+ DEFAULT_LIMIT = 25
83
+ MAX_LIMIT = 100
rdt_cli/exceptions.py ADDED
@@ -0,0 +1,69 @@
1
+ """Custom exceptions for Reddit CLI API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RedditApiError(Exception):
7
+ """Base exception for Reddit API errors."""
8
+
9
+ def __init__(self, message: str, code: int | str | None = None, response: dict | None = None):
10
+ super().__init__(message)
11
+ self.code = code
12
+ self.response = response
13
+
14
+
15
+ class SessionExpiredError(RedditApiError):
16
+ """Raised when session cookies have expired."""
17
+
18
+ def __init__(self):
19
+ super().__init__(
20
+ "Session expired. Please re-login: rdt logout && rdt login",
21
+ code=401,
22
+ )
23
+
24
+
25
+ class AuthRequiredError(RedditApiError):
26
+ """Raised when user is not logged in."""
27
+
28
+ def __init__(self):
29
+ super().__init__("Not logged in. Use 'rdt login' to authenticate")
30
+
31
+
32
+ class RateLimitError(RedditApiError):
33
+ """Raised when Reddit rate-limits the request."""
34
+
35
+ def __init__(self, retry_after: float | None = None):
36
+ msg = "Rate limited by Reddit"
37
+ if retry_after:
38
+ msg += f" (retry after {retry_after:.0f}s)"
39
+ super().__init__(msg, code=429)
40
+ self.retry_after = retry_after
41
+
42
+
43
+ class NotFoundError(RedditApiError):
44
+ """Raised when a subreddit, user, or post is not found."""
45
+
46
+ def __init__(self, resource: str = "Resource"):
47
+ super().__init__(f"{resource} not found", code=404)
48
+
49
+
50
+ class ForbiddenError(RedditApiError):
51
+ """Raised when access is forbidden (private subreddit, etc.)."""
52
+
53
+ def __init__(self, resource: str = "Resource"):
54
+ super().__init__(f"Access forbidden: {resource}", code=403)
55
+
56
+
57
+ def error_code_for_exception(exc: Exception) -> str:
58
+ """Map domain exceptions to stable error code strings."""
59
+ if isinstance(exc, (AuthRequiredError, SessionExpiredError)):
60
+ return "not_authenticated"
61
+ if isinstance(exc, RateLimitError):
62
+ return "rate_limited"
63
+ if isinstance(exc, NotFoundError):
64
+ return "not_found"
65
+ if isinstance(exc, ForbiddenError):
66
+ return "forbidden"
67
+ if isinstance(exc, RedditApiError):
68
+ return "api_error"
69
+ return "unknown_error"
rdt_cli/index_cache.py ADDED
@@ -0,0 +1,77 @@
1
+ """Search result index cache for short-index navigation (rdt show 3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from typing import Any
9
+
10
+ from .constants import CONFIG_DIR
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ INDEX_CACHE_FILE = CONFIG_DIR / "index_cache.json"
15
+
16
+
17
+ def save_index(items: list[dict], source: str = "search") -> None:
18
+ """Save a list of posts/items to the index cache."""
19
+ if not items:
20
+ return
21
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
22
+
23
+ entries = []
24
+ for item in items:
25
+ entry = {
26
+ "id": item.get("id", ""),
27
+ "name": item.get("name", ""), # fullname like t3_abc123
28
+ "title": item.get("title", ""),
29
+ "subreddit": item.get("subreddit", ""),
30
+ "author": item.get("author", ""),
31
+ "score": item.get("score", 0),
32
+ "num_comments": item.get("num_comments", 0),
33
+ "permalink": item.get("permalink", ""),
34
+ "url": item.get("url", ""),
35
+ }
36
+ if entry["id"]:
37
+ entries.append(entry)
38
+
39
+ payload = {
40
+ "source": source,
41
+ "saved_at": time.time(),
42
+ "count": len(entries),
43
+ "items": entries,
44
+ }
45
+ INDEX_CACHE_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
46
+ INDEX_CACHE_FILE.chmod(0o600)
47
+ logger.debug("Saved %d items to index cache (source=%s)", len(entries), source)
48
+
49
+
50
+ def get_item_by_index(index: int) -> dict | None:
51
+ """Get a cached item by 1-based index."""
52
+ if index <= 0 or not INDEX_CACHE_FILE.exists():
53
+ return None
54
+ try:
55
+ data = json.loads(INDEX_CACHE_FILE.read_text())
56
+ items = data.get("items", [])
57
+ if index <= len(items):
58
+ return items[index - 1]
59
+ return None
60
+ except (OSError, json.JSONDecodeError, IndexError):
61
+ return None
62
+
63
+
64
+ def get_index_info() -> dict[str, Any]:
65
+ """Get metadata about the current index cache."""
66
+ if not INDEX_CACHE_FILE.exists():
67
+ return {"exists": False, "count": 0}
68
+ try:
69
+ data = json.loads(INDEX_CACHE_FILE.read_text())
70
+ return {
71
+ "exists": True,
72
+ "count": data.get("count", 0),
73
+ "source": data.get("source", ""),
74
+ "saved_at": data.get("saved_at", 0),
75
+ }
76
+ except (OSError, json.JSONDecodeError):
77
+ return {"exists": False, "count": 0}