arcade-mcp/toolkits/reddit/arcade_reddit/utils.py
Sam Partee b6b4cd0a4c
🏗️ Restructure: Multi-Package Architecture + uv Migration (#412)
### Overview
Major restructuring from monolithic `arcade-ai` package to modular
library architecture with standardized uv-based dependency management.

![arcade-ai Monorepo
(2)](https://github.com/user-attachments/assets/25f102b0-bb87-4a04-9701-d227d05664b1)

### New Package Structure
- **`arcade-tdk`** - Lightweight toolkit development kit (core
decorators, auth)
- **`arcade-core`** - Core execution engine and catalog functionality  
- **`arcade-serve`** - FastAPI/MCP server components
- **`arcade-ai`** - Meta package that includes CLI functionality.
Optionally include evals via the `evals` extra. Optionally include all
packages via the `all` extra.

### Key Benefits
- **Lighter Dependencies**: Toolkits now depend only on `arcade-tdk` (~2
deps) vs full `arcade-ai` (~30+ deps)
- **Faster Builds**: uv provides 10-100x faster dependency resolution
and installation
- **Better Modularity**: Clear separation of concerns, consumers import
only what they need
- **Standard Tooling**: Eliminates custom poetry scripts, uses standard
Python packaging

### Migration Impact
- All 20 toolkits converted from poetry → uv with `arcade-tdk`
dependencies plus `arcade-ai[evals]` and `arcade-serve` dev
dependencies. When developing locally, devs should install toolkits via
`make install-local`.
- Modern Python 3.10+ type hints throughout
- Standardized build system with hatchling backend
- Enhanced Makefile with robust toolkit management commands
- Removed `arcade dev` CLI command
- Reduce the number of files created by `arcade new` and add an option
to not generate a tests and evals folder.

This foundation enables faster development cycles and cleaner dependency
chains for the growing toolkit ecosystem.

### Todo After this PR is merged
- [ ] Post-merge workflow(s) (release & publish containers, etc)
- [ ] Release order plan. @EricGustin suggests releasing in the
following order:
    1. `arcade-core` version 0.1.0
    2. `arcade-serve` version 0.1.0 and `arcade-tdk` version 0.1.0
    3. `arcade-ai` version 2.0.0
4. Patch release for all toolkits (all changes in toolkits are internal
refactors)
- [ ] [Update docs](https://github.com/ArcadeAI/docs/pull/318)

---------

Co-authored-by: Eric Gustin <eric@arcade.dev>
Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
2025-06-11 16:48:17 -07:00

463 lines
15 KiB
Python

import re
from urllib.parse import urlparse
import httpx
from arcade_tdk import ToolContext
from arcade_tdk.errors import ToolExecutionError
from arcade_reddit.client import RedditClient
from arcade_reddit.enums import RedditThingType
def remove_none_values(data: dict) -> dict:
"""Remove all keys with None values from a dictionary"""
return {k: v for k, v in data.items() if v is not None}
def normalize_subreddit_name(subreddit: str) -> str:
"""Normalize a subreddit name"""
return subreddit.lower().replace("r/", "").replace(" ", "")
def _simplify_post_data(post_data: dict, include_body: bool = False) -> dict:
simplified_data = {
"id": post_data.get("id"),
"name": post_data.get("name"),
"title": post_data.get("title"),
"author": post_data.get("author"),
"subreddit": post_data.get("subreddit"),
"created_utc": post_data.get("created_utc"),
"num_comments": post_data.get("num_comments"),
"score": post_data.get("score"),
"upvote_ratio": post_data.get("upvote_ratio"),
"upvotes": post_data.get("ups"),
"permalink": post_data.get("permalink"),
"url": post_data.get("url"),
"is_video": post_data.get("is_video"),
}
if include_body:
simplified_data["body"] = post_data.get("selftext")
return simplified_data
def parse_get_posts_in_subreddit_response(data: dict) -> dict:
"""Parse the response from the Reddit API for getting posts in a subreddit
Associated Reddit API endpoints:
https://www.reddit.com/dev/api/#GET_hot
https://www.reddit.com/dev/api/#GET_new
https://www.reddit.com/dev/api/#GET_rising
https://www.reddit.com/dev/api/#GET_{sort}
Args:
data: The response from the Reddit API deserialized as a dictionary.
NOTE: The response doesn't contain the body of the posts.
Returns:
A dictionary with a cursor for the next page and a list of posts
"""
posts = []
for child in data.get("data", {}).get("children", []):
post_data = child.get("data", {})
post = _simplify_post_data(post_data)
posts.append(post)
result = {"cursor": data.get("data", {}).get("after"), "posts": posts}
return result
def parse_get_content_of_post_response(data: list) -> dict:
"""Parse the json representation of a Reddit post to get the content of a post
Args:
data: The json representation of a Reddit post
(retrieved by appending .json to the permalink)
Returns:
A dictionary with the content of the post
"""
if not data or not isinstance(data, list) or len(data) == 0:
return {}
try:
post_data = data[0].get("data", {}).get("children", [{}])[0].get("data", {})
return _simplify_post_data(post_data, include_body=True)
except (IndexError, AttributeError, KeyError):
return {}
def parse_get_content_of_multiple_posts_response(data: dict) -> list[dict]:
"""Parse the json representation of multiple Reddit posts to get the content of each post
Args:
data: The json representation of multiple Reddit posts
(retrieved from the /api/info.json endpoint)
Returns:
A dictionary with the simplified content of each post
"""
if not data or not isinstance(data, dict) or len(data) == 0:
return []
result = []
for post in data.get("data", {}).get("children", []):
post_data = post.get("data", {})
result.append(_simplify_post_data(post_data, include_body=True))
return result
def parse_get_top_level_comments_response(data: list) -> dict:
"""Parse the json representation of a Reddit post to get the top-level comments
Args:
data: The json representation of a Reddit post
Returns:
A dictionary with a list of top-level comments
"""
try:
comments_listing = data[1]["data"]["children"]
except (IndexError, KeyError):
return {"comments": [], "num_comments": 0}
comments = []
for comment in comments_listing:
if comment.get("kind") != RedditThingType.COMMENT.value:
continue
comment_data = comment.get("data", {})
comments.append({
"id": comment_data.get("id"),
"author": comment_data.get("author"),
"body": comment_data.get("body"),
"score": comment_data.get("score"),
"created_utc": comment_data.get("created_utc"),
})
return {"comments": comments, "num_comments": len(comments)}
def parse_api_comment_response(data: dict) -> dict:
"""Parse the response from the Reddit API's /api/comment endpoint
Args:
data: The response from the Reddit API deserialized as a dictionary
Returns:
A dictionary with the comment data
"""
result = {
"created_utc": data.get("created_utc"),
"name": data.get("name"),
"parent_id": data.get("parent_id"),
"permalink": data.get("permalink"),
"subreddit": data.get("subreddit"),
"subreddit_id": data.get("subreddit_id"),
"subreddit_name_prefixed": data.get("subreddit_name_prefixed"),
}
return result
def _extract_id_from_url(identifier: str, regex: str, error_msg: str) -> str:
"""
Extract an ID from a Reddit URL using the provided regular expression.
Args:
identifier: The URL string from which to extract the ID.
regex: The regular expression pattern containing a capturing group for the ID.
error_msg: The error message to use if no ID can be extracted.
Returns:
The extracted ID as a string.
Raises:
ToolExecutionError: If the URL is not a Reddit URL or the pattern does not match.
"""
parsed = urlparse(identifier)
if not parsed.netloc.endswith("reddit.com"):
raise ToolExecutionError(
message=f"Expected a reddit URL, but got: {identifier}",
developer_message="The identifier should be a valid Reddit URL.",
)
match = re.search(regex, parsed.path)
if not match:
raise ToolExecutionError(
message=f"Could not extract id from URL: {identifier}",
developer_message=error_msg,
)
return match.group(1)
def _extract_id_from_permalink(identifier: str, regex: str, error_msg: str) -> str:
"""
Extract an ID from a Reddit permalink using the provided regular expression.
Args:
identifier: The permalink string from which to extract the ID.
regex: The regular expression pattern containing a capturing group for the ID.
error_msg: The error message to use if no ID can be extracted.
Returns:
The extracted ID as a string.
Raises:
ToolExecutionError: If the pattern does not match the permalink.
"""
match = re.search(regex, identifier)
if not match:
raise ToolExecutionError(
message=f"Could not extract id from permalink: {identifier}",
developer_message=error_msg,
)
return match.group(1)
def _get_post_id(identifier: str) -> str:
"""
Retrieve the post ID from various types of Reddit post identifiers.
The identifier can be a Reddit URL to the post, a permalink for the post,
a fullname for the post (starting with 't3_'), or a raw post ID.
Args:
identifier: The Reddit post identifier.
Returns:
The post ID as a string.
Raises:
ToolExecutionError: If the identifier does not contain a valid post ID.
"""
if identifier.startswith("http://") or identifier.startswith("https://"):
return _extract_id_from_url(
identifier,
r"/comments/([A-Za-z0-9]+)",
"The reddit URL does not contain a valid post id.",
)
elif identifier.startswith("/r/"):
return _extract_id_from_permalink(
identifier,
r"/comments/([A-Za-z0-9]+)",
"The permalink does not contain a valid post id.",
)
else:
pattern = re.compile(r"^(t3_)?([A-Za-z0-9]+)$")
match = pattern.match(identifier)
if match:
return match.group(2)
raise ToolExecutionError(
message=f"Invalid identifier: {identifier}",
developer_message=(
"The identifier should be a valid Reddit URL, permalink, fullname, or post id."
),
)
def _get_comment_id(identifier: str) -> str:
"""
Retrieve the comment ID from various types of Reddit comment identifiers.
The identifier can be a Reddit URL to the comment, a permalink for the comment,
a fullname for the comment (starting with 't1_'), or a raw comment ID.
Args:
identifier: The Reddit comment identifier.
Returns:
The comment ID as a string.
Raises:
ToolExecutionError: If the identifier does not contain a valid comment ID.
"""
if identifier.startswith("http://") or identifier.startswith("https://"):
return _extract_id_from_url(
identifier,
r"/comment/([A-Za-z0-9]+)",
"The reddit URL does not contain a valid comment id.",
)
elif identifier.startswith("/r/"):
return _extract_id_from_permalink(
identifier,
r"/comment/([A-Za-z0-9]+)",
"The permalink does not contain a valid comment id.",
)
else:
if identifier.startswith("t1_"):
return identifier[3:]
if re.fullmatch(r"[A-Za-z0-9]+", identifier):
return identifier
raise ToolExecutionError(
message=f"Invalid identifier: {identifier}",
developer_message=(
"The identifier should be a valid Reddit URL, permalink, fullname, or comment id."
),
)
def create_path_for_post(identifier: str) -> str:
"""
Create a path for a Reddit post.
Args:
identifier: The identifier of the post. The identifier may be a reddit URL,
a permalink for the post, a fullname for the post, or a post id.
Returns:
The path for the post.
"""
if identifier.startswith("http://") or identifier.startswith("https://"):
parsed = urlparse(identifier)
if not parsed.netloc.endswith("reddit.com"):
raise ToolExecutionError(
message=f"Expected a reddit URL, but got: {identifier}",
developer_message="The identifier should be a valid Reddit URL.",
)
return parsed.path
if identifier.startswith("/r/"):
return identifier
post_id = _get_post_id(identifier)
return f"/comments/{post_id}"
def create_fullname_for_post(identifier: str) -> str:
"""
Create a fullname for a Reddit post.
Args:
identifier: The identifier of the post. The identifier may be a reddit URL,
a permalink for the post, a fullname for the post, or a post id.
Returns:
The fullname for the post.
"""
if identifier.startswith("t3_"):
return identifier
post_id = _get_post_id(identifier)
return f"t3_{post_id}"
def create_fullname_for_multiple_posts(post_identifiers: list[str]) -> tuple[list[str], list[dict]]:
"""
Create fullnames for multiple Reddit posts.
Args:
post_identifiers: A list of Reddit post identifiers. The identifiers may be
reddit URLs, permalinks, fullnames, or post ids.
Returns:
(fullnames, warnings): A tuple of a list of fullnames for the posts and
a list of warnings if any of the identifiers are invalid.
"""
fullnames = []
warnings = []
for identifier in post_identifiers:
try:
fullnames.append(create_fullname_for_post(identifier))
except ToolExecutionError:
message = f"'{identifier}' is not a valid Reddit post identifier."
warnings.append({"message": message, "identifier": identifier})
return fullnames, warnings
def create_fullname_for_comment(identifier: str) -> str:
"""
Create a fullname for a Reddit comment.
Args:
identifier: The identifier of the comment. The identifier may be a
reddit URL to the comment, a permalink for the comment, a fullname for
the comment, or a comment id.
Returns:
The fullname for the comment.
"""
if identifier.startswith("t1_"):
return identifier
comment_id = _get_comment_id(identifier)
return f"t1_{comment_id}"
async def resolve_subreddit_access(client: RedditClient, subreddit: str) -> dict:
"""Checks whether the specified subreddit exists and is accessible.
Helps abstract the logic of checking subreddit access.
Args:
client: The Reddit client
subreddit: The subreddit to check
Returns:
A dictionary that specifies whether the subreddit exists and
whether it is accessible to the user.
"""
normalized_name = normalize_subreddit_name(subreddit)
try:
await client.get(f"r/{normalized_name}/about.json")
except httpx.HTTPStatusError as e:
if e.response.status_code in (404, 302):
return {"exists": False, "accessible": False}
elif e.response.status_code == 403:
return {"exists": True, "accessible": False}
raise
return {"exists": True, "accessible": True}
def parse_subreddit_rules_response(data: dict) -> dict:
"""
Parse the response data from the Reddit API for subreddit rules.
Args:
data (dict): The raw API response containing subreddit rules.
Returns:
dict: A dictionary with a 'rules' key containing a list of parsed rules.
"""
rules = []
for rule in data.get("rules", []):
rules.append({
"priority": rule.get("priority"),
"title": rule.get("short_name"),
"body": rule.get("description"),
})
return {"rules": rules}
async def parse_user_posts_response(
context: ToolContext, posts_data: dict, include_body: bool
) -> dict:
"""Parse the response from the Reddit API for user posts
Args:
context: The tool context
posts_data: The response from the Reddit API for getting the authenticated user's posts
include_body: Whether to include the body of the posts in the parsed response
Returns:
A dictionary with a cursor for the next page (if there is one) and a list of posts
"""
next_cursor = posts_data.get("data", {}).get("after")
parsed_response = {"cursor": next_cursor} if next_cursor else {}
if not include_body:
posts = []
for child in posts_data.get("data", {}).get("children", []):
post_data = child.get("data", {})
simplified = _simplify_post_data(post_data, include_body=False)
posts.append(simplified)
parsed_response["posts"] = posts
else:
post_ids = []
for child in posts_data.get("data", {}).get("children", []):
post_data = child.get("data", {})
identifier = post_data.get("name") or post_data.get("id")
if identifier:
post_ids.append(identifier)
# Dynamically import get_content_of_multiple_posts to avoid circular dependency
from arcade_reddit.tools.read import get_content_of_multiple_posts
content_response = await get_content_of_multiple_posts(
context=context, post_identifiers=post_ids
)
posts_with_body = content_response.get("posts", [])
parsed_response["posts"] = posts_with_body
return parsed_response