arcade-mcp/toolkits/x/arcade_x/tools/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

162 lines
5.3 KiB
Python

from typing import Any
from arcade_tdk import ToolContext
from arcade_tdk.errors import ToolExecutionError
def get_tweet_url(tweet_id: str) -> str:
"""Get the URL of a tweet given its ID."""
return f"https://x.com/x/status/{tweet_id}"
def get_headers_with_token(context: ToolContext) -> dict[str, str]:
"""Get the headers for a request to the X API."""
if context.authorization is None or context.authorization.token is None:
raise ToolExecutionError(
"Missing Token. Authorization is required to post a tweet.",
developer_message="Token is not set in the ToolContext.",
)
token = (
context.authorization.token if context.authorization and context.authorization.token else ""
)
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def parse_search_recent_tweets_response(response_data: dict[str, Any]) -> dict[str, Any]:
"""
Parses response from the X API search recent tweets endpoint.
Returns the modified response data with added 'tweet_url', 'author_username', and 'author_name'.
"""
if not sanity_check_tweets_data(response_data):
return {"data": [], "next_token": ""}
# Add 'tweet_url' to each tweet
for tweet in response_data["data"]:
tweet["tweet_url"] = get_tweet_url(tweet["id"])
# Add 'author_username' and 'author_name' to each tweet
for tweet_data, user_data in zip(
response_data["data"], response_data["includes"]["users"], strict=False
):
tweet_data["author_username"] = user_data["username"]
tweet_data["author_name"] = user_data["name"]
return response_data
def sanity_check_tweets_data(tweets_data: dict[str, Any]) -> bool:
"""
Sanity check the tweets data.
Returns True if the tweets data is valid and contains tweets, False otherwise.
"""
if not tweets_data.get("data"):
return False
# prefer clarity over appeasing linter here
if not tweets_data.get("includes", {}).get("users"): # noqa: SIM103
return False
return True
def expand_long_tweet(tweet_data: dict[str, Any]) -> None:
"""Expand a long tweet.
For tweets exceeding 280 characters,
replace the truncated tweet text with the full tweet text.
"""
if tweet_data.get("note_tweet"):
tweet_data["text"] = tweet_data["note_tweet"]["text"]
del tweet_data["note_tweet"]
def expand_urls_in_tweets(
tweets_data: list[dict[str, Any]], delete_entities: bool = True
) -> list[dict[str, Any]]:
"""
Returns a new list of tweets with expanded URLs.
"""
new_tweets = []
for tweet_data in tweets_data:
new_tweet = tweet_data.copy()
if "entities" in new_tweet and "urls" in new_tweet["entities"]:
for url_entity in new_tweet["entities"]["urls"]:
short_url = url_entity["url"]
expanded_url = url_entity["expanded_url"]
new_tweet["text"] = new_tweet["text"].replace(short_url, expanded_url)
if delete_entities:
new_tweet.pop("entities", None)
new_tweets.append(new_tweet)
return new_tweets
def expand_urls_in_user_description(user_data: dict, delete_entities: bool = True) -> dict:
"""
Returns a new user data dict with expanded URLs in the description.
"""
new_user_data = user_data.copy()
description_urls = new_user_data.get("entities", {}).get("description", {}).get("urls", [])
description = new_user_data.get("description", "")
for url_info in description_urls:
t_co_link = url_info["url"]
expanded_url = url_info["expanded_url"]
description = description.replace(t_co_link, expanded_url)
new_user_data["description"] = description
if delete_entities:
new_user_data.pop("entities", None)
return new_user_data
def expand_urls_in_user_url(user_data: dict, delete_entities: bool = True) -> dict:
"""
Returns a new user data dict with expanded URLs in the URL field.
"""
new_user_data = user_data.copy()
url_urls = new_user_data.get("entities", {}).get("url", {}).get("urls", [])
url = new_user_data.get("url", "")
for url_info in url_urls:
t_co_link = url_info["url"]
expanded_url = url_info["expanded_url"]
url = url.replace(t_co_link, expanded_url)
new_user_data["url"] = url
if delete_entities:
new_user_data.pop("entities", None)
return new_user_data
def remove_none_values(params: dict) -> dict:
"""
Remove key/value pairs with None values from a dictionary.
Args:
params: The dictionary to clean
Returns:
A new dictionary with None values removed
"""
return {k: v for k, v in params.items() if v is not None}
def expand_attached_media(params: dict) -> dict:
"""
Include attached media metadata in the request parameters.
"""
params["expansions"] += ",attachments.media_keys"
params["tweet.fields"] += ",attachments"
params["media.fields"] = ",".join([
# media_key, url and type are returned by default, added here for clarity
"media_key",
"url",
"type",
"duration_ms",
"height",
"width",
"preview_image_url",
"alt_text",
"public_metrics",
])
return params