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,353 @@
1
+ """Common helpers for Reddit CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Callable
11
+ from datetime import datetime, timezone
12
+ from typing import Any, TypeVar
13
+
14
+ import click
15
+ from rich.console import Console
16
+
17
+ from ..auth import Credential, get_credential
18
+ from ..client import RedditClient
19
+ from ..exceptions import RedditApiError, SessionExpiredError, error_code_for_exception
20
+
21
+ T = TypeVar("T")
22
+
23
+ console = Console(stderr=True)
24
+ error_console = Console(stderr=True)
25
+ _stdout = Console()
26
+
27
+ _SCHEMA_VERSION = "1"
28
+ _OUTPUT_ENV = "OUTPUT"
29
+
30
+
31
+ # ── Shared formatters (DRY — used by browse, search, post) ──────────
32
+
33
+
34
+ def format_score(score: int) -> str:
35
+ """Format score as human-readable string (e.g., 1.2k)."""
36
+ if score >= 1000:
37
+ return f"{score / 1000:.1f}k"
38
+ return str(score)
39
+
40
+
41
+ def format_time(ts: float) -> str:
42
+ """Format Unix timestamp to relative time string."""
43
+ if not ts:
44
+ return "-"
45
+ now = datetime.now(timezone.utc).timestamp()
46
+ diff = now - ts
47
+ if diff < 0:
48
+ return "just now"
49
+ if diff < 60:
50
+ return f"{int(diff)}s ago"
51
+ if diff < 3600:
52
+ return f"{int(diff / 60)}m ago"
53
+ if diff < 86400:
54
+ return f"{int(diff / 3600)}h ago"
55
+ if diff < 604800:
56
+ return f"{int(diff / 86400)}d ago"
57
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
58
+
59
+
60
+ # ── Output format resolution ────────────────────────────────────────
61
+
62
+
63
+ def resolve_output_format(*, as_json: bool, as_yaml: bool) -> str | None:
64
+ """Resolve explicit flags first, then env override, then TTY default.
65
+
66
+ Returns "json", "yaml", or None (for rich rendering).
67
+ """
68
+ if as_json and as_yaml:
69
+ raise click.UsageError("Use only one of --json or --yaml.")
70
+ if as_json:
71
+ return "json"
72
+ if as_yaml:
73
+ return "yaml"
74
+
75
+ output_mode = os.getenv(_OUTPUT_ENV, "auto").strip().lower()
76
+ if output_mode == "yaml":
77
+ return "yaml"
78
+ if output_mode == "json":
79
+ return "json"
80
+ if output_mode == "rich":
81
+ return None
82
+
83
+ if not sys.stdout.isatty():
84
+ return "yaml"
85
+ return None
86
+
87
+
88
+ # ── Structured output (stable agent envelope) ──────────────────────
89
+
90
+
91
+ def success_payload(data: Any) -> dict[str, Any]:
92
+ """Wrap structured success data in the shared agent schema."""
93
+ return {
94
+ "ok": True,
95
+ "schema_version": _SCHEMA_VERSION,
96
+ "data": data,
97
+ }
98
+
99
+
100
+ def error_payload(code: str, message: str, *, details: Any | None = None) -> dict[str, Any]:
101
+ """Wrap structured error data in the shared agent schema."""
102
+ error: dict[str, Any] = {
103
+ "code": code,
104
+ "message": message,
105
+ }
106
+ if details is not None:
107
+ error["details"] = details
108
+ return {
109
+ "ok": False,
110
+ "schema_version": _SCHEMA_VERSION,
111
+ "error": error,
112
+ }
113
+
114
+
115
+ def print_json(data: Any) -> None:
116
+ """Print raw JSON output to stdout."""
117
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
118
+
119
+
120
+ def print_yaml(data: Any) -> None:
121
+ """Print raw YAML output to stdout."""
122
+ try:
123
+ import yaml
124
+
125
+ click.echo(yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False))
126
+ except ImportError:
127
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
128
+
129
+
130
+ def maybe_print_structured(data: Any, *, as_json: bool, as_yaml: bool) -> bool:
131
+ """Print structured output (with envelope) when requested or when stdout is non-TTY.
132
+
133
+ Returns True if output was printed, False if rich rendering should be used.
134
+ """
135
+ fmt = resolve_output_format(as_json=as_json, as_yaml=as_yaml)
136
+ if not fmt:
137
+ return False
138
+ payload = success_payload(data)
139
+ if fmt == "json":
140
+ print_json(payload)
141
+ else:
142
+ print_yaml(payload)
143
+ return True
144
+
145
+
146
+ def emit_error(
147
+ code: str,
148
+ message: str,
149
+ *,
150
+ as_json: bool | None = None,
151
+ as_yaml: bool | None = None,
152
+ details: Any | None = None,
153
+ ) -> bool:
154
+ """Emit a structured error when the active output mode is machine-readable.
155
+
156
+ Returns True if the error was emitted as structured output.
157
+ """
158
+ if as_json is None or as_yaml is None:
159
+ ctx = click.get_current_context(silent=True)
160
+ params = ctx.params if ctx is not None else {}
161
+ as_json = bool(params.get("as_json", False)) if as_json is None else as_json
162
+ as_yaml = bool(params.get("as_yaml", False)) if as_yaml is None else as_yaml
163
+
164
+ fmt = resolve_output_format(as_json=bool(as_json), as_yaml=bool(as_yaml))
165
+ if fmt is None:
166
+ return False
167
+
168
+ payload = error_payload(code, message, details=details)
169
+ if fmt == "json":
170
+ print_json(payload)
171
+ else:
172
+ print_yaml(payload)
173
+ return True
174
+
175
+
176
+ # ── Auth / Client helpers ───────────────────────────────────────────
177
+
178
+
179
+ def require_auth() -> Credential:
180
+ """Get credential or exit with error."""
181
+ cred = get_credential()
182
+ if not cred:
183
+ console.print("[yellow]⚠️ Not logged in[/yellow]. Use [bold]rdt login[/bold] to authenticate")
184
+ sys.exit(1)
185
+ return cred
186
+
187
+
188
+ def optional_auth() -> Credential | None:
189
+ """Get credential if available, or None (for public endpoints)."""
190
+ return get_credential()
191
+
192
+
193
+ def get_client(credential: Credential | None = None) -> RedditClient:
194
+ """Create a RedditClient with optional credential."""
195
+ return RedditClient(credential)
196
+
197
+
198
+ def run_client_action(credential: Credential | None, action: Callable[[RedditClient], T]) -> T:
199
+ """Run a client action with auto-retry on session expiry."""
200
+ try:
201
+ with get_client(credential) as client:
202
+ return action(client)
203
+ except SessionExpiredError:
204
+ from ..auth import extract_browser_credential
205
+
206
+ fresh = extract_browser_credential()
207
+ if fresh:
208
+ with get_client(fresh) as client:
209
+ return action(client)
210
+ raise
211
+
212
+
213
+ def handle_command(
214
+ credential: Credential | None,
215
+ *,
216
+ action: Callable[[RedditClient], T],
217
+ render: Callable[[T], None] | None = None,
218
+ as_json: bool = False,
219
+ as_yaml: bool = False,
220
+ ) -> T | None:
221
+ """Run a client action with structured output support.
222
+
223
+ - --json → JSON stdout (with envelope)
224
+ - --yaml or non-TTY → YAML (with envelope)
225
+ - Otherwise → rich render
226
+
227
+ On error: emits structured error + exit(1).
228
+ """
229
+ try:
230
+ data = run_client_action(credential, action)
231
+
232
+ if maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml):
233
+ return data
234
+
235
+ if render:
236
+ render(data)
237
+ return data
238
+
239
+ except RedditApiError as exc:
240
+ exit_for_error(exc, as_json=as_json, as_yaml=as_yaml)
241
+ return None # unreachable, but for type checker
242
+
243
+
244
+ def handle_errors(fn: Callable[[], T], *, as_json: bool = False, as_yaml: bool = False) -> T | None:
245
+ """Run arbitrary command logic and catch RedditApiError."""
246
+ try:
247
+ return fn()
248
+ except RedditApiError as exc:
249
+ exit_for_error(exc, as_json=as_json, as_yaml=as_yaml)
250
+ return None
251
+
252
+
253
+ def exit_for_error(
254
+ exc: Exception,
255
+ *,
256
+ as_json: bool = False,
257
+ as_yaml: bool = False,
258
+ prefix: str | None = None,
259
+ ) -> None:
260
+ """Emit a structured/non-structured error and terminate the command."""
261
+ message = str(exc)
262
+ if prefix:
263
+ message = f"{prefix}: {message}"
264
+
265
+ code = error_code_for_exception(exc)
266
+
267
+ if emit_error(code, message, as_json=as_json, as_yaml=as_yaml):
268
+ raise SystemExit(1) from None
269
+
270
+ error_console.print(f"[red]❌ [{code}] {message}[/red]")
271
+ raise SystemExit(1) from None
272
+
273
+
274
+ def structured_output_options(command: Callable) -> Callable:
275
+ """Add --json/--yaml options to a Click command."""
276
+ command = click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML")(command)
277
+ command = click.option("--json", "as_json", is_flag=True, help="Output as JSON")(command)
278
+ return command
279
+
280
+
281
+ def listing_options(command: Callable) -> Callable:
282
+ """Add --json/--yaml/--output/--full-text/--compact options to listing commands."""
283
+ command = click.option(
284
+ "-c", "--compact", is_flag=True,
285
+ help="Compact output (fewer fields, agent-friendly)",
286
+ )(command)
287
+ command = click.option(
288
+ "--full-text", "full_text", is_flag=True,
289
+ help="Show full title/text without truncation",
290
+ )(command)
291
+ command = click.option(
292
+ "-o", "--output", "output_file", default=None,
293
+ help="Save structured output to file (JSON/YAML)",
294
+ )(command)
295
+ command = click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML")(command)
296
+ command = click.option("--json", "as_json", is_flag=True, help="Output as JSON")(command)
297
+ return command
298
+
299
+
300
+ def output_or_render(data: Any, *, as_json: bool, as_yaml: bool, render: Callable) -> None:
301
+ """DRY output routing: JSON / YAML (with envelope) / Rich."""
302
+ if maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml):
303
+ return
304
+ render(data)
305
+
306
+
307
+ def save_output_to_file(data: Any, output_file: str) -> None:
308
+ """Save structured output to a file (auto-detect JSON/YAML by extension)."""
309
+ payload = success_payload(data)
310
+ ext = output_file.rsplit(".", 1)[-1].lower() if "." in output_file else "json"
311
+ if ext in ("yml", "yaml"):
312
+ try:
313
+ import yaml
314
+ text = yaml.dump(payload, allow_unicode=True, default_flow_style=False, sort_keys=False)
315
+ except ImportError:
316
+ text = json.dumps(payload, indent=2, ensure_ascii=False)
317
+ else:
318
+ text = json.dumps(payload, indent=2, ensure_ascii=False)
319
+ with open(output_file, "w", encoding="utf-8") as f:
320
+ f.write(text)
321
+ console.print(f"[green]✅ Saved to {output_file}[/green]")
322
+
323
+
324
+ def compact_posts(posts: list[dict]) -> list[dict]:
325
+ """Strip non-essential fields for agent-friendly compact output."""
326
+ keep = {"id", "name", "title", "subreddit", "author", "score", "num_comments", "permalink", "url", "created_utc"}
327
+ return [{k: v for k, v in p.items() if k in keep} for p in posts]
328
+
329
+
330
+ def write_delay() -> None:
331
+ """Random delay for write operations (1.5-4s) to mitigate rate limits."""
332
+ import random
333
+ import time
334
+
335
+ delay = random.uniform(1.5, 4.0)
336
+ time.sleep(delay)
337
+
338
+
339
+ def open_url(url: str) -> None:
340
+ """Open a URL in the default browser."""
341
+ system = platform.system()
342
+ try:
343
+ if system == "Darwin":
344
+ subprocess.run(["open", url], check=True)
345
+ elif system == "Linux":
346
+ subprocess.run(["xdg-open", url], check=True)
347
+ elif system == "Windows":
348
+ subprocess.run(["start", url], check=True, shell=True)
349
+ else:
350
+ click.echo(url)
351
+ except (FileNotFoundError, subprocess.CalledProcessError):
352
+ click.echo(url)
353
+
@@ -0,0 +1,105 @@
1
+ """Auth commands: login, logout, status, whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ._common import (
8
+ console,
9
+ handle_command,
10
+ maybe_print_structured,
11
+ require_auth,
12
+ structured_output_options,
13
+ )
14
+
15
+
16
+ @click.command()
17
+ def login() -> None:
18
+ """Extract browser cookies for Reddit authentication"""
19
+ from ..auth import extract_browser_credential, get_credential
20
+
21
+ # Check if already logged in
22
+ cred = get_credential()
23
+ if cred:
24
+ console.print("[green]✅ Already authenticated[/green]")
25
+ return
26
+
27
+ console.print("[dim]🔍 Searching for Reddit cookies in browsers...[/dim]")
28
+ cred = extract_browser_credential()
29
+ if cred:
30
+ console.print(f"[green]✅ Login successful![/green] ({len(cred.cookies)} cookies extracted)")
31
+ else:
32
+ console.print("[red]❌ No Reddit cookies found.[/red]")
33
+ console.print(" [dim]Please login to reddit.com in your browser first, then retry.[/dim]")
34
+
35
+
36
+ @click.command()
37
+ def logout() -> None:
38
+ """Clear saved Reddit cookies"""
39
+ from ..auth import clear_credential
40
+
41
+ clear_credential()
42
+ console.print("[green]✅ Credentials cleared[/green]")
43
+
44
+
45
+ @click.command()
46
+ @structured_output_options
47
+ def status(as_json: bool, as_yaml: bool) -> None:
48
+ """Check authentication status"""
49
+ from ..auth import CREDENTIAL_FILE, get_credential
50
+
51
+ cred = get_credential()
52
+ info = {
53
+ "authenticated": cred is not None,
54
+ "cookie_count": len(cred.cookies) if cred else 0,
55
+ "credential_file": str(CREDENTIAL_FILE),
56
+ }
57
+
58
+ if maybe_print_structured(info, as_json=as_json, as_yaml=as_yaml):
59
+ return
60
+
61
+ if cred:
62
+ console.print(f"[green]✅ Authenticated[/green] ({len(cred.cookies)} cookies)")
63
+ if "reddit_session" in cred.cookies:
64
+ console.print(" [dim]reddit_session: ✓[/dim]")
65
+ else:
66
+ console.print("[yellow]⚠️ Not authenticated[/yellow]")
67
+ console.print(" [dim]Use 'rdt login' to extract cookies from your browser[/dim]")
68
+
69
+
70
+ @click.command()
71
+ @structured_output_options
72
+ def whoami(as_json: bool, as_yaml: bool) -> None:
73
+ """Show current user profile (karma, account age)"""
74
+ from rich.panel import Panel
75
+
76
+ from ._common import format_time
77
+
78
+ cred = require_auth()
79
+
80
+ def _render(data: dict) -> None:
81
+ name = data.get("name", "?")
82
+ karma_post = data.get("link_karma", 0)
83
+ karma_comment = data.get("comment_karma", 0)
84
+ total_karma = data.get("total_karma", karma_post + karma_comment)
85
+ created = data.get("created_utc", 0)
86
+ is_gold = "⭐ " if data.get("is_gold") else ""
87
+ is_mod = "🛡️ " if data.get("is_mod") else ""
88
+
89
+ text = (
90
+ f"[bold cyan]u/{name}[/bold cyan] {is_gold}{is_mod}\n"
91
+ f"📊 Total karma: {total_karma:,}\n"
92
+ f" Post: {karma_post:,} · Comment: {karma_comment:,}\n"
93
+ f"📅 Joined: {format_time(created)}\n"
94
+ )
95
+
96
+ panel = Panel(text, title="👤 Me", border_style="green")
97
+ console.print(panel)
98
+
99
+ handle_command(
100
+ cred,
101
+ action=lambda c: c.get_user_about(cred.cookies.get("reddit_user", "me")),
102
+ render=_render,
103
+ as_json=as_json,
104
+ as_yaml=as_yaml,
105
+ )