better-reddit-cli 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,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: better-reddit-cli
3
+ Version: 0.1.0
4
+ Summary: A command-line tool for browsing Reddit without requiring an API key
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.14
7
+ Requires-Dist: httpx
8
+ Requires-Dist: pydantic
9
+ Requires-Dist: typer
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ <p align="center">
15
+ <picture>
16
+ <source media="(prefers-color-scheme: dark)" srcset="public/banner.jpg">
17
+ <source media="(prefers-color-scheme: light)" srcset="public/banner.jpg">
18
+ <img src="public/banner.jpg" alt="Reddit CLI Logo" width="100%">
19
+ </picture>
20
+ </p>
21
+
22
+ <h1 align="center">Reddit CLI</h1>
23
+
24
+ <p align="center">
25
+ Browse Reddit from your terminal. No API key, no authentication, no hassle.
26
+ </p>
27
+
28
+ <p align="center">
29
+ <a href="https://github.com/AliiiBenn/reddit-cli">
30
+ <img src="https://img.shields.io/github/license/AliiiBenn/reddit-cli" alt="License">
31
+ </a>
32
+ <a href="https://github.com/AliiiBenn/reddit-cli/actions">
33
+ <img src="https://img.shields.io/github/actions/workflow/status/AliiiBenn/reddit-cli/test" alt="Tests">
34
+ </a>
35
+ <a href="https://www.python.org/">
36
+ <img src="https://img.shields.io/badge/python-3.14+-blue" alt="Python">
37
+ </a>
38
+ </p>
39
+
40
+ Explore subreddits, dive into discussions, and discover trending content - all from a sleek command-line interface.
41
+
42
+ ## Why Reddit CLI?
43
+
44
+ - **Zero setup** - No API key needed, just install and go
45
+ - **Blazing fast** - Async HTTP powered by httpx
46
+ - **Explore deeply** - Pagination, sorting, and threaded comments
47
+ - **Clean output** - Readable formatting right in your terminal
48
+
49
+ ## Features
50
+
51
+ - Browse subreddits with 6 sorting modes (hot, new, top, rising, controversial, gilded)
52
+ - View posts with full metadata
53
+ - Threaded comment display with depth control
54
+ - Pagination support for infinite scrolling
55
+ - Subreddit info, rules, and moderator discovery
56
+ - 100% type-annotated Python
57
+
58
+ ## Quick Start
59
+
60
+ ```bash
61
+ # Install
62
+ uv add reddit
63
+
64
+ # Browse the frontpage
65
+ reddit
66
+
67
+ # Explore a subreddit
68
+ reddit browse python --limit 10
69
+
70
+ # View a post and its comments
71
+ reddit view t3_abc123
72
+ reddit comments t3_abc123 --depth 3
73
+
74
+ # Discover subreddits
75
+ reddit subreddits --sort subscribers
76
+ reddit subreddit python --rules
77
+ ```
78
+
79
+ ## Command Overview
80
+
81
+ | Command | Description |
82
+ |---------|-------------|
83
+ | `reddit` | Frontpage (hot posts) |
84
+ | `reddit browse <sub>` | Browse a subreddit |
85
+ | `reddit view <id>` | View a post |
86
+ | `reddit comments <id>` | View post comments |
87
+ | `reddit subreddit <name>` | Subreddit info |
88
+ | `reddit subreddits` | List popular subreddits |
89
+
90
+ ## Browse Options
91
+
92
+ ```
93
+ --sort hot|new|top|rising|controversial|gilded
94
+ --limit <n> Number of posts (max 100)
95
+ --period day|week|month|year|all
96
+ --after <id> Next page
97
+ --before <id> Previous page
98
+ ```
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ # Clone and install
104
+ git clone https://github.com/AliiiBenn/reddit-cli.git
105
+ cd reddit-cli
106
+ uv sync
107
+
108
+ # Run tests
109
+ uv run pytest
110
+
111
+ # Lint and type-check
112
+ uv run ruff check .
113
+ uv run mypy reddit_cli
114
+
115
+ # Try it out
116
+ uv run reddit browse python
117
+ ```
118
+
119
+ ## Contributing
120
+
121
+ Contributions are welcome! Feel free to open issues or submit PRs.
122
+
123
+ ## License
124
+
125
+ MIT - See [LICENSE](LICENSE) for details.
@@ -0,0 +1,17 @@
1
+ reddit_cli/__init__.py,sha256=sb4RueWnGOwAsGYNj9aV1ScWbwH5bZemsYUnZRZBIzI,1349
2
+ reddit_cli/commands/browse.py,sha256=pJOtDOj_HleOhaUc46o2RTUi1ns3ujW34Bo3d92i7t0,1734
3
+ reddit_cli/commands/comments.py,sha256=YhaSnRLU_wAlYc5JmtiFukRBDFxS61G-NHCcnmiznJM,2892
4
+ reddit_cli/commands/navigation.py,sha256=I9_iphn_Vbv3tv3cZDUhoiK5AX-4fWZYqKg_r9Gruo8,2649
5
+ reddit_cli/commands/post.py,sha256=nqTz_6xt5cusblV0Jy7Zd6dQbXtyZT3Qx6mDi5QUEZs,1195
6
+ reddit_cli/commands/subreddit.py,sha256=RCpge9fulRiUb7TurzWSYydeClb83sgP78Adwu8VA6c,2806
7
+ reddit_cli/reddit/__init__.py,sha256=T_MXBIZN2c346BY-rEHCsmSQlorPKRcZ84jMTVKNidA,383
8
+ reddit_cli/reddit/base.py,sha256=g7TPjcwxyQquVZ4JpaGBR1WiR9XI-8Vshgy-L1x_o0g,837
9
+ reddit_cli/reddit/comments.py,sha256=akBLWyaZsYmeqq2JZihaY7sfhbhd750OZimpnKSBWzA,2695
10
+ reddit_cli/reddit/models.py,sha256=s53AL5u6lmSSiFs4nh72DIJlDVZClO2y0GQmeZ4pgq4,981
11
+ reddit_cli/reddit/posts.py,sha256=Mt73QEN-zkxfY4XKHfLoERr1ilAQQzyJoOetH9i_ZNU,2119
12
+ reddit_cli/reddit/subreddits.py,sha256=4DoHfNG0w9VzwNUjp9iCQsPG2IpCTKZesOKSIvPl7J4,1873
13
+ better_reddit_cli-0.1.0.dist-info/METADATA,sha256=N019Vqt6n15M8aiQUrFszkwYG4o6VN1aOPPMnwwc9iY,3177
14
+ better_reddit_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ better_reddit_cli-0.1.0.dist-info/entry_points.txt,sha256=Xx3prHxNe-6GZlmfKwnsoEil-o6jQMncVvwsjra6WmE,42
16
+ better_reddit_cli-0.1.0.dist-info/licenses/LICENSE,sha256=zGjJxjLOT5T07cl55eVmhhm3pm38odPhe0aB9nUFaTI,1067
17
+ better_reddit_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ reddit = reddit_cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Reddit CLI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
reddit_cli/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ import asyncio
2
+ import typer
3
+
4
+ from reddit_cli.commands.browse import browse
5
+ from reddit_cli.commands.comments import comment, comments
6
+ from reddit_cli.commands.navigation import best, frontpage, home
7
+ from reddit_cli.commands.post import post, view
8
+ from reddit_cli.commands.subreddit import subreddit, subreddits
9
+ from reddit_cli.reddit import RedditClient, PostsClient
10
+
11
+ app = typer.Typer(invoke_without_command=True)
12
+ app.command()(browse)
13
+ app.command()(post)
14
+ app.command()(view)
15
+ app.command()(comments)
16
+ app.command()(comment)
17
+ app.command()(subreddit)
18
+ app.command()(subreddits)
19
+ app.command()(frontpage)
20
+ app.command()(home)
21
+ app.command()(best)
22
+
23
+
24
+ @app.callback()
25
+ def main() -> None:
26
+ """Browse the frontpage by default."""
27
+ asyncio.run(_frontpage_default())
28
+
29
+
30
+ async def _frontpage_default() -> None:
31
+ """Default: show frontpage."""
32
+ async with RedditClient() as client:
33
+ posts_client = PostsClient(client)
34
+ posts, after_cursor, before_cursor = await posts_client.list_posts("reddit", "hot", 25, None)
35
+
36
+ for post in posts:
37
+ print(f"[{post.score}] {post.title}")
38
+ print(f" ID: {post.id}")
39
+ print(f" r/{post.subreddit} by {post.author}")
40
+ print(f" {post.num_comments} comments")
41
+ print()
42
+
43
+
44
+ @app.command()
45
+ def ping() -> str:
46
+ """Ping the CLI."""
47
+ return "pong"
@@ -0,0 +1,58 @@
1
+ import asyncio
2
+ import typer
3
+
4
+ from reddit_cli.reddit import RedditClient, PostsClient
5
+
6
+ app = typer.Typer()
7
+
8
+
9
+ async def _browse_async(
10
+ subreddit: str,
11
+ sort: str = "hot",
12
+ limit: int = 25,
13
+ period: str | None = None,
14
+ after: str | None = None,
15
+ before: str | None = None,
16
+ ) -> None:
17
+ """Async implementation of browse."""
18
+ async with RedditClient() as client:
19
+ posts_client = PostsClient(client)
20
+ posts, after_cursor, before_cursor = await posts_client.list_posts(
21
+ subreddit, sort, limit, period, after, before
22
+ )
23
+
24
+ for post in posts:
25
+ print(f"[{post.score}] {post.title}")
26
+ print(f" ID: {post.id}")
27
+ print(f" r/{post.subreddit} by {post.author}")
28
+ print(f" {post.num_comments} comments")
29
+ print()
30
+
31
+ if after_cursor or before_cursor:
32
+ print("---")
33
+ if after_cursor:
34
+ print(f"After: {after_cursor}")
35
+ if before_cursor:
36
+ print(f"Before: {before_cursor}")
37
+
38
+
39
+ @app.command()
40
+ def browse(
41
+ subreddit: str,
42
+ sort: str = "hot",
43
+ limit: int = 25,
44
+ period: str | None = None,
45
+ after: str | None = None,
46
+ before: str | None = None,
47
+ ) -> None:
48
+ """Browse posts from a subreddit.
49
+
50
+ Args:
51
+ subreddit: Subreddit name (without r/)
52
+ sort: Sort type (hot, new, top, rising, controversial, gilded)
53
+ limit: Number of posts to return
54
+ period: Time period for top/controversial (day, week, month, year, all)
55
+ after: Pagination cursor (get posts after this ID)
56
+ before: Pagination cursor (get posts before this ID)
57
+ """
58
+ asyncio.run(_browse_async(subreddit, sort, limit, period, after, before))
@@ -0,0 +1,101 @@
1
+ import asyncio
2
+ import sys
3
+ import typer
4
+
5
+ from reddit_cli.reddit import RedditClient, CommentsClient, Comment
6
+
7
+ app = typer.Typer()
8
+
9
+
10
+ async def _comments_async(
11
+ post_id: str,
12
+ sort: str = "confidence",
13
+ depth: int | None = None,
14
+ ) -> None:
15
+ """Async implementation of comments."""
16
+ async with RedditClient() as client:
17
+ comments_client = CommentsClient(client)
18
+ comments = await comments_client.get_comments(post_id, sort, depth)
19
+
20
+ for comment in comments:
21
+ _print_comment(comment)
22
+
23
+
24
+ def _print_comment(comment: "Comment", indent: int = 0) -> None:
25
+ """Print a comment and its replies recursively."""
26
+ prefix = " " * indent
27
+ body = comment.body.encode(sys.stdout.encoding, errors="replace").decode(
28
+ sys.stdout.encoding
29
+ )
30
+
31
+ print(f"{prefix}[{comment.score}] {comment.author}")
32
+ print(f"{prefix}{body[:200]}{'...' if len(body) > 200 else ''}")
33
+ print()
34
+
35
+ for reply in comment.replies:
36
+ _print_comment(reply, indent + 1)
37
+
38
+
39
+ @app.command()
40
+ def comments(
41
+ post_id: str,
42
+ sort: str = "confidence",
43
+ depth: int | None = None,
44
+ ) -> None:
45
+ """View comments for a post.
46
+
47
+ Args:
48
+ post_id: Post ID (with or without t3_ prefix)
49
+ sort: Sort type (confidence, top, new, old, controversial, qa)
50
+ depth: Maximum comment depth
51
+ """
52
+ asyncio.run(_comments_async(post_id, sort, depth))
53
+
54
+
55
+ # Alias
56
+ @app.command(name="comment")
57
+ def comment(
58
+ post_id: str,
59
+ comment_id: str,
60
+ replies: bool = False,
61
+ ) -> None:
62
+ """View a single comment from a post.
63
+
64
+ Args:
65
+ post_id: Post ID (with or without t3_ prefix)
66
+ comment_id: Comment ID (with or without t1_ prefix)
67
+ replies: Include replies
68
+ """
69
+ asyncio.run(_comment_async(post_id, comment_id, replies))
70
+
71
+
72
+ async def _comment_async(
73
+ post_id: str,
74
+ comment_id: str,
75
+ replies: bool = False,
76
+ ) -> None:
77
+ """Async implementation of single comment."""
78
+ async with RedditClient() as client:
79
+ comments_client = CommentsClient(client)
80
+ depth = 999 if replies else 0
81
+ comments = await comments_client.get_comments(post_id, depth=depth)
82
+
83
+ # Find the specific comment
84
+ target_id = comment_id.removeprefix("t1_")
85
+ for c in comments:
86
+ if c.id == target_id:
87
+ print(f"[{c.score}] {c.author}")
88
+ print(f"ID: {c.id}")
89
+ print(f"Parent: {c.parent_id}")
90
+ body = c.body.encode(sys.stdout.encoding, errors="replace").decode(
91
+ sys.stdout.encoding
92
+ )
93
+ print(body)
94
+ print()
95
+ if replies and c.replies:
96
+ print("Replies:")
97
+ for reply in c.replies:
98
+ _print_comment(reply, 1)
99
+ return
100
+
101
+ print(f"Comment {comment_id} not found in post {post_id}")
@@ -0,0 +1,93 @@
1
+ import asyncio
2
+ import typer
3
+
4
+ from reddit_cli.reddit import RedditClient, PostsClient
5
+
6
+ app = typer.Typer()
7
+
8
+
9
+ async def _browse_frontpage_async(
10
+ sort: str = "hot",
11
+ limit: int = 25,
12
+ period: str | None = None,
13
+ after: str | None = None,
14
+ before: str | None = None,
15
+ ) -> None:
16
+ """Async implementation of frontpage browsing."""
17
+ async with RedditClient() as client:
18
+ posts_client = PostsClient(client)
19
+ posts, after_cursor, before_cursor = await posts_client.list_posts(
20
+ "reddit", sort, limit, period, after, before
21
+ )
22
+
23
+ for post in posts:
24
+ print(f"[{post.score}] {post.title}")
25
+ print(f" ID: {post.id}")
26
+ print(f" r/{post.subreddit} by {post.author}")
27
+ print(f" {post.num_comments} comments")
28
+ print()
29
+
30
+ if after_cursor or before_cursor:
31
+ print("---")
32
+ if after_cursor:
33
+ print(f"After: {after_cursor}")
34
+ if before_cursor:
35
+ print(f"Before: {before_cursor}")
36
+
37
+
38
+ @app.command(name="frontpage")
39
+ def frontpage(
40
+ sort: str = "hot",
41
+ limit: int = 25,
42
+ period: str | None = None,
43
+ after: str | None = None,
44
+ before: str | None = None,
45
+ ) -> None:
46
+ """Browse the frontpage.
47
+
48
+ Args:
49
+ sort: Sort type (hot, new, top, rising, controversial, gilded)
50
+ limit: Number of posts to return
51
+ period: Time period for top/controversial (day, week, month, year, all)
52
+ after: Pagination cursor
53
+ before: Pagination cursor
54
+ """
55
+ asyncio.run(_browse_frontpage_async(sort, limit, period, after, before))
56
+
57
+
58
+ @app.command(name="home")
59
+ def home(
60
+ sort: str = "hot",
61
+ limit: int = 25,
62
+ period: str | None = None,
63
+ after: str | None = None,
64
+ before: str | None = None,
65
+ ) -> None:
66
+ """Alias for frontpage.
67
+
68
+ Args:
69
+ sort: Sort type (hot, new, top, rising, controversial, gilded)
70
+ limit: Number of posts to return
71
+ period: Time period for top/controversial (day, week, month, year, all)
72
+ after: Pagination cursor
73
+ before: Pagination cursor
74
+ """
75
+ asyncio.run(_browse_frontpage_async(sort, limit, period, after, before))
76
+
77
+
78
+ @app.command(name="best")
79
+ def best(
80
+ limit: int = 25,
81
+ period: str = "all",
82
+ after: str | None = None,
83
+ before: str | None = None,
84
+ ) -> None:
85
+ """Browse the best posts overall.
86
+
87
+ Args:
88
+ limit: Number of posts to return
89
+ period: Time period (day, week, month, year, all)
90
+ after: Pagination cursor
91
+ before: Pagination cursor
92
+ """
93
+ asyncio.run(_browse_frontpage_async("top", limit, period, after, before))
@@ -0,0 +1,45 @@
1
+ import asyncio
2
+ import sys
3
+ import typer
4
+
5
+ from reddit_cli.reddit import RedditClient, PostsClient
6
+
7
+ app = typer.Typer()
8
+
9
+
10
+ async def _post_async(post_id: str) -> None:
11
+ """Async implementation of post."""
12
+ async with RedditClient() as client:
13
+ posts_client = PostsClient(client)
14
+ post = await posts_client.get_post(post_id)
15
+
16
+ print(f"Title: {post.title}")
17
+ print(f"Score: {post.score}")
18
+ print(f"Comments: {post.num_comments}")
19
+ print(f"Author: {post.author}")
20
+ print(f"Subreddit: r/{post.subreddit}")
21
+ print(f"URL: {post.url}")
22
+ print(f"Permalink: https://reddit.com{post.permalink}")
23
+ print()
24
+ if post.selftext:
25
+ print(post.selftext.encode(sys.stdout.encoding, errors="replace").decode(sys.stdout.encoding))
26
+
27
+
28
+ @app.command()
29
+ def post(post_id: str) -> None:
30
+ """View a single post by ID.
31
+
32
+ Args:
33
+ post_id: Post ID (with or without t3_ prefix)
34
+ """
35
+ asyncio.run(_post_async(post_id))
36
+
37
+
38
+ @app.command(name="view")
39
+ def view(post_id: str) -> None:
40
+ """Alias for post command.
41
+
42
+ Args:
43
+ post_id: Post ID (with or without t3_ prefix)
44
+ """
45
+ asyncio.run(_post_async(post_id))
@@ -0,0 +1,88 @@
1
+ import asyncio
2
+ import sys
3
+ import typer
4
+
5
+ from reddit_cli.reddit import RedditClient, SubredditsClient
6
+
7
+ app = typer.Typer()
8
+
9
+
10
+ async def _subreddit_async(
11
+ name: str,
12
+ rules: bool = False,
13
+ moderators: bool = False,
14
+ ) -> None:
15
+ """Async implementation of subreddit."""
16
+ async with RedditClient() as client:
17
+ subreddits_client = SubredditsClient(client)
18
+
19
+ if rules:
20
+ rules_data = await subreddits_client.get_rules(name)
21
+ print(f"Rules for r/{name}:")
22
+ for i, rule in enumerate(rules_data.get("rules", []), 1):
23
+ print(f" {i}. {rule.get('short_name', 'N/A')}")
24
+ print(f" {rule.get('description', 'N/A')[:100]}...")
25
+ elif moderators:
26
+ try:
27
+ mods_data = await subreddits_client.get_moderators(name)
28
+ print(f"Moderators of r/{name}:")
29
+ for mod in mods_data:
30
+ print(f" - {mod.get('name', 'N/A')}")
31
+ except Exception:
32
+ print("Moderators list is not publicly available (requires authentication)")
33
+ else:
34
+ subreddit = await subreddits_client.get_subreddit(name)
35
+ print(f"Subreddit: r/{subreddit.display_name}")
36
+ print(f"Title: {subreddit.title}")
37
+ print(f"Subscribers: {subreddit.subscribers:,}")
38
+ print(f"Active users: {subreddit.active_users:,}")
39
+ desc = subreddit.description.encode(
40
+ sys.stdout.encoding, errors="replace"
41
+ ).decode(sys.stdout.encoding)
42
+ print(f"Description: {desc[:300]}{'...' if len(desc) > 300 else ''}")
43
+
44
+
45
+ @app.command()
46
+ def subreddit(
47
+ name: str,
48
+ rules: bool = False,
49
+ moderators: bool = False,
50
+ ) -> None:
51
+ """Get subreddit info.
52
+
53
+ Args:
54
+ name: Subreddit name (with or without r/ prefix)
55
+ rules: Show subreddit rules
56
+ moderators: Show subreddit moderators
57
+ """
58
+ asyncio.run(_subreddit_async(name, rules, moderators))
59
+
60
+
61
+ @app.command(name="subreddits")
62
+ def subreddits(
63
+ sort: str = "subscribers",
64
+ limit: int = 25,
65
+ ) -> None:
66
+ """List popular subreddits.
67
+
68
+ Args:
69
+ sort: Sort type (subscribers, active)
70
+ limit: Number of subreddits to return
71
+ """
72
+ asyncio.run(_list_subreddits_async(sort, limit))
73
+
74
+
75
+ async def _list_subreddits_async(
76
+ sort: str = "subscribers",
77
+ limit: int = 25,
78
+ ) -> None:
79
+ """Async implementation of subreddits listing."""
80
+ async with RedditClient() as client:
81
+ subreddits_client = SubredditsClient(client)
82
+ subreddits = await subreddits_client.list_subreddits(sort, limit)
83
+
84
+ for sub in subreddits:
85
+ print(f"r/{sub.display_name}")
86
+ print(f" {sub.title}")
87
+ print(f" Subscribers: {sub.subscribers:,}")
88
+ print()
@@ -0,0 +1,7 @@
1
+ from reddit_cli.reddit.base import RedditClient
2
+ from reddit_cli.reddit.comments import CommentsClient
3
+ from reddit_cli.reddit.models import Comment, Post, Subreddit
4
+ from reddit_cli.reddit.posts import PostsClient
5
+ from reddit_cli.reddit.subreddits import SubredditsClient
6
+
7
+ __all__ = ["RedditClient", "PostsClient", "CommentsClient", "SubredditsClient", "Post", "Subreddit", "Comment"]
@@ -0,0 +1,26 @@
1
+ import httpx
2
+
3
+
4
+ class RedditClient:
5
+ """Async HTTP client for Reddit JSON API."""
6
+
7
+ BASE_URL = "https://www.reddit.com"
8
+
9
+ def __init__(self) -> None:
10
+ self._client: httpx.AsyncClient | None = None
11
+
12
+ async def __aenter__(self) -> "RedditClient":
13
+ self._client = httpx.AsyncClient(base_url=self.BASE_URL)
14
+ return self
15
+
16
+ async def __aexit__(self, *args: object) -> None:
17
+ if self._client:
18
+ await self._client.aclose()
19
+
20
+ async def get(self, path: str, params: dict | None = None) -> dict:
21
+ """Make a GET request to the Reddit API."""
22
+ if not self._client:
23
+ raise RuntimeError("Client not initialized. Use async context manager.")
24
+ response = await self._client.get(path, params=params)
25
+ response.raise_for_status()
26
+ return response.json()
@@ -0,0 +1,81 @@
1
+ from reddit_cli.reddit.base import RedditClient
2
+ from reddit_cli.reddit.models import Comment
3
+
4
+
5
+ class CommentsClient:
6
+ """Client for Reddit comment endpoints."""
7
+
8
+ def __init__(self, client: RedditClient) -> None:
9
+ self._client = client
10
+
11
+ async def get_comments(
12
+ self,
13
+ post_id: str,
14
+ sort: str = "confidence",
15
+ depth: int | None = None,
16
+ ) -> list[Comment]:
17
+ """Get comments for a post.
18
+
19
+ Args:
20
+ post_id: Post ID (with or without t3_ prefix)
21
+ sort: Sort type (confidence, top, new, old, controversial, qa)
22
+ depth: Maximum comment depth (None for unlimited)
23
+ """
24
+ if post_id.startswith("t3_"):
25
+ post_id = post_id[3:]
26
+
27
+ path = f"/comments/{post_id}.json"
28
+ params = f"?sort={sort}"
29
+
30
+ data = await self._client.get(path + params)
31
+ comments_data = data[1]["data"]["children"]
32
+
33
+ comments = []
34
+ for item in comments_data:
35
+ if item["kind"] == "t1": # Comment
36
+ comment = self._parse_comment(item["data"], depth or 999, 0)
37
+ comments.append(comment)
38
+
39
+ return comments
40
+
41
+ def _parse_comment(
42
+ self, data: dict, max_depth: int, current_depth: int
43
+ ) -> Comment:
44
+ """Parse a comment and its replies recursively."""
45
+ replies: list[Comment] = []
46
+
47
+ if data.get("replies") and isinstance(data["replies"], dict):
48
+ for reply in data["replies"]["data"]["children"]:
49
+ if reply["kind"] == "t1" and current_depth < max_depth:
50
+ replies.append(
51
+ self._parse_comment(
52
+ reply["data"], max_depth, current_depth + 1
53
+ )
54
+ )
55
+
56
+ return Comment(
57
+ id=data["id"],
58
+ author=data["author"],
59
+ body=data["body"],
60
+ score=data["score"],
61
+ created_utc=data["created_utc"],
62
+ parent_id=data["parent_id"],
63
+ link_id=data["link_id"],
64
+ depth=current_depth,
65
+ replies=replies,
66
+ )
67
+
68
+ async def get_comment(self, comment_id: str) -> Comment:
69
+ """Get a single comment by ID.
70
+
71
+ Args:
72
+ comment_id: Comment ID (with or without t1_ prefix)
73
+ """
74
+ if comment_id.startswith("t1_"):
75
+ comment_id = comment_id[3:]
76
+
77
+ data = await self._client.get(f"/by_id/t1_{comment_id}.json")
78
+ # Response is a list with [0] = post, [1] = comments
79
+ # The comment itself is nested in the listing
80
+ comment_data = data[1]["data"]["children"][0]["data"]
81
+ return self._parse_comment(comment_data, 0, 0)
@@ -0,0 +1,51 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class Post(BaseModel):
5
+ """Reddit post model."""
6
+
7
+ id: str
8
+ title: str
9
+ author: str
10
+ subreddit: str
11
+ score: int
12
+ num_comments: int
13
+ permalink: str
14
+ url: str
15
+ created_utc: float
16
+ selftext: str = ""
17
+
18
+ @property
19
+ def short_id(self) -> str:
20
+ """Return the post short ID (without prefix)."""
21
+ return self.id
22
+
23
+
24
+ class Subreddit(BaseModel):
25
+ """Subreddit model."""
26
+
27
+ id: str
28
+ display_name: str
29
+ title: str
30
+ description: str
31
+ subscribers: int
32
+ active_users: int = Field(default=0, validation_alias="accounts_active")
33
+
34
+
35
+ class Comment(BaseModel):
36
+ """Reddit comment model."""
37
+
38
+ id: str
39
+ author: str
40
+ body: str
41
+ score: int
42
+ created_utc: float
43
+ parent_id: str
44
+ link_id: str
45
+ depth: int = 0
46
+ replies: list["Comment"] = []
47
+
48
+ @property
49
+ def short_id(self) -> str:
50
+ """Return the comment short ID (without prefix)."""
51
+ return self.id
@@ -0,0 +1,61 @@
1
+ from reddit_cli.reddit.base import RedditClient
2
+ from reddit_cli.reddit.models import Post
3
+
4
+
5
+ class PostsClient:
6
+ """Client for Reddit post endpoints."""
7
+
8
+ def __init__(self, client: RedditClient) -> None:
9
+ self._client = client
10
+
11
+ async def list_posts(
12
+ self,
13
+ subreddit: str,
14
+ sort: str = "hot",
15
+ limit: int = 25,
16
+ period: str | None = None,
17
+ after: str | None = None,
18
+ before: str | None = None,
19
+ ) -> tuple[list[Post], str | None, str | None]:
20
+ """List posts from a subreddit.
21
+
22
+ Args:
23
+ subreddit: Subreddit name (without r/)
24
+ sort: Sort type (hot, new, top, rising, controversial, gilded)
25
+ limit: Number of posts to return (max 100)
26
+ period: Time period for top/controversial (day, week, month, year, all)
27
+ after: Pagination cursor (get posts after this ID)
28
+ before: Pagination cursor (get posts before this ID)
29
+
30
+ Returns:
31
+ Tuple of (posts, after_cursor, before_cursor)
32
+ """
33
+ path = f"/r/{subreddit}/{sort}.json"
34
+
35
+ params: dict[str, int | str] = {"limit": limit}
36
+ if period and sort in ("top", "controversial"):
37
+ params["t"] = period
38
+ if after:
39
+ params["after"] = after
40
+ if before:
41
+ params["before"] = before
42
+
43
+ data = await self._client.get(path, params=params)
44
+ posts = data.get("data", {}).get("children", [])
45
+ after_cursor = data.get("data", {}).get("after")
46
+ before_cursor = data.get("data", {}).get("before")
47
+
48
+ return [Post(**post["data"]) for post in posts], after_cursor, before_cursor
49
+
50
+ async def get_post(self, post_id: str) -> Post:
51
+ """Get a single post by ID.
52
+
53
+ Args:
54
+ post_id: Post ID (with or without t3_ prefix)
55
+ """
56
+ if post_id.startswith("t3_"):
57
+ post_id = post_id[3:]
58
+
59
+ # We need the subreddit to fetch the post, so we search by id first
60
+ data = await self._client.get(f"/by_id/t3_{post_id}.json")
61
+ return Post(**data["data"]["children"][0]["data"])
@@ -0,0 +1,63 @@
1
+ from reddit_cli.reddit.base import RedditClient
2
+ from reddit_cli.reddit.models import Subreddit
3
+
4
+
5
+ class SubredditsClient:
6
+ """Client for Reddit subreddit endpoints."""
7
+
8
+ def __init__(self, client: RedditClient) -> None:
9
+ self._client = client
10
+
11
+ async def get_subreddit(self, name: str) -> Subreddit:
12
+ """Get subreddit info.
13
+
14
+ Args:
15
+ name: Subreddit name (with or without r/ prefix)
16
+ """
17
+ if name.startswith("r/"):
18
+ name = name[2:]
19
+
20
+ data = await self._client.get(f"/r/{name}/about.json")
21
+ return Subreddit(**data["data"])
22
+
23
+ async def get_rules(self, name: str) -> dict:
24
+ """Get subreddit rules.
25
+
26
+ Args:
27
+ name: Subreddit name (with or without r/ prefix)
28
+ """
29
+ if name.startswith("r/"):
30
+ name = name[2:]
31
+
32
+ data = await self._client.get(f"/r/{name}/about/rules.json")
33
+ return data
34
+
35
+ async def get_moderators(self, name: str) -> list[dict]:
36
+ """Get subreddit moderators.
37
+
38
+ Args:
39
+ name: Subreddit name (with or without r/ prefix)
40
+ """
41
+ if name.startswith("r/"):
42
+ name = name[2:]
43
+
44
+ data = await self._client.get(f"/r/{name}/about/moderators.json")
45
+ return data["data"]["children"]
46
+
47
+ async def list_subreddits(
48
+ self,
49
+ sort: str = "subscribers",
50
+ limit: int = 25,
51
+ ) -> list[Subreddit]:
52
+ """List popular subreddits.
53
+
54
+ Args:
55
+ sort: Sort type (subscribers, active)
56
+ limit: Number of subreddits to return
57
+ """
58
+ path = "/subreddits.json"
59
+ params: dict[str, int | str] = {"limit": limit, "sort": sort}
60
+
61
+ data = await self._client.get(path, params=params)
62
+ subreddits = data.get("data", {}).get("children", [])
63
+ return [Subreddit(**sub["data"]) for sub in subreddits]