diff --git a/.ruff.toml b/.ruff.toml index 35542e31..54915aae 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -56,7 +56,6 @@ ignore = [ [lint.per-file-ignores] "**/tests/*" = ["S101"] -"toolkits/*" = ["A002", "TRY300", "C901", "C416", "S113", "RUF013", "SIM103"] # TODO: Remove everything here [format] preview = true diff --git a/toolkits/google/arcade_google/tools/gmail.py b/toolkits/google/arcade_google/tools/gmail.py index 7b3e5845..0dc63683 100644 --- a/toolkits/google/arcade_google/tools/gmail.py +++ b/toolkits/google/arcade_google/tools/gmail.py @@ -14,6 +14,8 @@ from arcade.sdk import tool from arcade.sdk.auth import Google from arcade_google.tools.utils import ( DateRange, + build_query_string, + fetch_messages, get_draft_url, get_email_in_trash_url, get_sent_email_url, @@ -79,7 +81,7 @@ async def send_email( ) ) async def send_draft_email( - context: ToolContext, id: Annotated[str, "The ID of the draft to send"] + context: ToolContext, email_id: Annotated[str, "The ID of the draft to send"] ) -> Annotated[str, "A confirmation message with the sent email ID and URL"]: """ Send a draft email using the Gmail API. @@ -90,7 +92,7 @@ async def send_draft_email( service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) # Send the draft email - sent_message = service.users().drafts().send(userId="me", body={"id": id}).execute() + sent_message = service.users().drafts().send(userId="me", body={"id": email_id}).execute() # Construct the URL to the sent email return f"Draft email with ID {sent_message['id']} sent: {get_sent_email_url(sent_message['id'])}" @@ -162,7 +164,7 @@ async def write_draft_email( ) async def update_draft_email( context: ToolContext, - id: Annotated[str, "The ID of the draft email to update."], + draft_email_id: Annotated[str, "The ID of the draft email to update."], subject: Annotated[str, "The subject of the draft email"], body: Annotated[str, "The body of the draft email"], recipient: Annotated[str, "The recipient of the draft email"], @@ -189,10 +191,10 @@ async def update_draft_email( raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode() # Update the draft - draft = {"id": id, "message": {"raw": raw_message}} + draft = {"id": draft_email_id, "message": {"raw": raw_message}} updated_draft_message = ( - service.users().drafts().update(userId="me", id=id, body=draft).execute() + service.users().drafts().update(userId="me", id=draft_email_id, body=draft).execute() ) return f"Draft email with ID {updated_draft_message['id']} updated: {get_draft_url(updated_draft_message['id'])}" except HttpError as e: @@ -214,7 +216,7 @@ async def update_draft_email( ) async def delete_draft_email( context: ToolContext, - id: Annotated[str, "The ID of the draft email to delete"], + draft_email_id: Annotated[str, "The ID of the draft email to delete"], ) -> Annotated[str, "A confirmation message indicating successful deletion"]: """ Delete a draft email using the Gmail API. @@ -225,8 +227,7 @@ async def delete_draft_email( service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) # Delete the draft - service.users().drafts().delete(userId="me", id=id).execute() - return f"Draft email with ID {id} deleted successfully." + service.users().drafts().delete(userId="me", id=draft_email_id).execute() except HttpError as e: raise ToolExecutionError( f"HttpError during execution of '{delete_draft_email.__name__}' tool.", @@ -237,6 +238,8 @@ async def delete_draft_email( f"Unexpected Error encountered during execution of '{delete_draft_email.__name__}' tool.", str(e), ) + else: + return f"Draft email with ID {draft_email_id} deleted successfully." # Email Management Tools @@ -246,7 +249,7 @@ async def delete_draft_email( ) ) async def trash_email( - context: ToolContext, id: Annotated[str, "The ID of the email to trash"] + context: ToolContext, email_id: Annotated[str, "The ID of the email to trash"] ) -> Annotated[str, "A confirmation message with the trashed email ID and URL"]: """ Move an email to the trash folder using the Gmail API. @@ -257,9 +260,9 @@ async def trash_email( service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) # Trash the email - service.users().messages().trash(userId="me", id=id).execute() + service.users().messages().trash(userId="me", id=email_id).execute() - return f"Email with ID {id} trashed successfully: {get_email_in_trash_url(id)}" + return f"Email with ID {email_id} trashed successfully: {get_email_in_trash_url(email_id)}" except HttpError as e: raise ToolExecutionError( f"HttpError during execution of '{trash_email.__name__}' tool.", str(e) @@ -339,53 +342,21 @@ async def list_emails_by_header( Search for emails by header using the Gmail API. At least one of the following parametersMUST be provided: sender, recipient, subject, body. """ - if not any([sender, recipient, subject, body]): raise ToolInputError( "At least one of sender, recipient, subject, or body must be provided." ) - # Build the query string - query = [] - if sender: - query.append(f"from:{sender}") - if recipient: - query.append(f"to:{recipient}") - if subject: - query.append(f"subject:{subject}") - if body: - query.append(body) - if date_range: - query.append(date_range.to_date_query()) - - query_string = " ".join(query) + query = build_query_string(sender, recipient, subject, body, date_range) try: - # Set up the Gmail API client service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) - - # Perform the search - response = ( - service.users() - .messages() - .list(userId="me", q=query_string, maxResults=limit or 100) - .execute() - ) - messages = response.get("messages", []) + messages = fetch_messages(service, query, limit) if not messages: return json.dumps({"emails": []}) - emails = [] - for msg in messages: - try: - email_data = service.users().messages().get(userId="me", id=msg["id"]).execute() - email_details = parse_email(email_data) - if email_details: - emails.append(email_details) - except HttpError as e: - print(f"Error reading email {msg['id']}: {e}") - + emails = process_messages(service, messages) return json.dumps({"emails": emails}) except HttpError as e: raise ToolExecutionError( @@ -399,6 +370,18 @@ async def list_emails_by_header( ) +def process_messages(service, messages): + emails = [] + for msg in messages: + try: + email_data = service.users().messages().get(userId="me", id=msg["id"]).execute() + email_details = parse_email(email_data) + emails += email_details if email_details else [] + except HttpError as e: + print(f"Error reading email {msg['id']}: {e}") + return emails + + @tool( requires_auth=Google( scopes=["https://www.googleapis.com/auth/gmail.readonly"], diff --git a/toolkits/google/arcade_google/tools/utils.py b/toolkits/google/arcade_google/tools/utils.py index 3da3436f..63c0a1b7 100644 --- a/toolkits/google/arcade_google/tools/utils.py +++ b/toolkits/google/arcade_google/tools/utils.py @@ -174,3 +174,34 @@ def _clean_text(text: str) -> str: text = "\n".join(line.strip() for line in text.split("\n")) return text + + +def build_query_string(sender, recipient, subject, body, date_range): + """ + Helper function to build a query string for Gmail list_emails_by_header tool. + """ + query = [] + if sender: + query.append(f"from:{sender}") + if recipient: + query.append(f"to:{recipient}") + if subject: + query.append(f"subject:{subject}") + if body: + query.append(body) + if date_range: + query.append(date_range.to_date_query()) + return " ".join(query) + + +def fetch_messages(service, query_string, limit): + """ + Helper function to fetch messages from Gmail API for the list_emails_by_header tool. + """ + response = ( + service.users() + .messages() + .list(userId="me", q=query_string, maxResults=limit or 100) + .execute() + ) + return response.get("messages", []) diff --git a/toolkits/google/tests/test_gmail.py b/toolkits/google/tests/test_gmail.py index d790956b..cc98c71d 100644 --- a/toolkits/google/tests/test_gmail.py +++ b/toolkits/google/tests/test_gmail.py @@ -99,7 +99,7 @@ async def test_update_draft_email(mock_build, mock_context): # Test happy path result = await update_draft_email( context=mock_context, - id="draft123", + draft_email_id="draft123", subject="Updated Subject", body="Updated Body", recipient="updated@example.com", @@ -117,7 +117,7 @@ async def test_update_draft_email(mock_build, mock_context): with pytest.raises(ToolExecutionError): await update_draft_email( context=mock_context, - id="nonexistent_draft", + draft_email_id="nonexistent_draft", subject="Updated Subject", body="Updated Body", recipient="updated@example.com", @@ -131,7 +131,7 @@ async def test_send_draft_email(mock_build, mock_context): mock_build.return_value = mock_service # Test happy path - result = await send_draft_email(context=mock_context, id="draft456") + result = await send_draft_email(context=mock_context, email_id="draft456") assert "Draft email with ID" in result assert "sent" in result @@ -143,7 +143,7 @@ async def test_send_draft_email(mock_build, mock_context): ) with pytest.raises(ToolExecutionError): - await send_draft_email(context=mock_context, id="nonexistent_draft") + await send_draft_email(context=mock_context, email_id="nonexistent_draft") @pytest.mark.asyncio @@ -153,7 +153,7 @@ async def test_delete_draft_email(mock_build, mock_context): mock_build.return_value = mock_service # Test happy path - result = await delete_draft_email(context=mock_context, id="draft789") + result = await delete_draft_email(context=mock_context, draft_email_id="draft789") assert "Draft email with ID" in result assert "deleted successfully" in result @@ -165,7 +165,7 @@ async def test_delete_draft_email(mock_build, mock_context): ) with pytest.raises(ToolExecutionError): - await delete_draft_email(context=mock_context, id="nonexistent_draft") + await delete_draft_email(context=mock_context, draft_email_id="nonexistent_draft") @pytest.mark.asyncio @@ -404,7 +404,7 @@ async def test_trash_email(mock_build, mock_context): # Test happy path email_id = "123456" - result = await trash_email(context=mock_context, id=email_id) + result = await trash_email(context=mock_context, email_id=email_id) assert ( f"Email with ID {email_id} trashed successfully: https://mail.google.com/mail/u/0/#trash/{email_id}" @@ -418,4 +418,4 @@ async def test_trash_email(mock_build, mock_context): ) with pytest.raises(ToolExecutionError): - await trash_email(context=mock_context, id="nonexistent_email") + await trash_email(context=mock_context, email_id="nonexistent_email") diff --git a/toolkits/math/arcade_math/tools/arithmetic.py b/toolkits/math/arcade_math/tools/arithmetic.py index f513ed0e..14ef148a 100644 --- a/toolkits/math/arcade_math/tools/arithmetic.py +++ b/toolkits/math/arcade_math/tools/arithmetic.py @@ -72,4 +72,4 @@ def sum_range( """ Sum all numbers from start through end """ - return sum([i for i in range(start, end + 1)]) + return sum(list(range(start, end + 1))) diff --git a/toolkits/x/arcade_x/tools/tweets.py b/toolkits/x/arcade_x/tools/tweets.py index a139de49..7e113eff 100644 --- a/toolkits/x/arcade_x/tools/tweets.py +++ b/toolkits/x/arcade_x/tools/tweets.py @@ -1,6 +1,6 @@ from typing import Annotated -import requests +import httpx from arcade.core.errors import ToolExecutionError from arcade.core.schema import ToolContext @@ -13,7 +13,7 @@ TWEETS_URL = "https://api.x.com/2/tweets" # Manage Tweets Tools. See developer docs for additional available parameters: https://developer.x.com/en/docs/x-api/tweets/manage-tweets/api-reference @tool(requires_auth=X(scopes=["tweet.read", "tweet.write", "users.read"])) -def post_tweet( +async def post_tweet( context: ToolContext, tweet_text: Annotated[str, "The text content of the tweet you want to post"], ) -> Annotated[str, "Success string and the URL of the tweet"]: @@ -25,7 +25,8 @@ def post_tweet( } payload = {"text": tweet_text} - response = requests.post(TWEETS_URL, headers=headers, json=payload) + async with httpx.AsyncClient() as client: + response = await client.post(TWEETS_URL, headers=headers, json=payload, timeout=10) if response.status_code != 201: raise ToolExecutionError( @@ -37,7 +38,7 @@ def post_tweet( @tool(requires_auth=X(scopes=["tweet.read", "tweet.write", "users.read"])) -def delete_tweet_by_id( +async def delete_tweet_by_id( context: ToolContext, tweet_id: Annotated[str, "The ID of the tweet you want to delete"], ) -> Annotated[str, "Success string confirming the tweet deletion"]: @@ -46,7 +47,8 @@ def delete_tweet_by_id( headers = {"Authorization": f"Bearer {context.authorization.token}"} url = f"{TWEETS_URL}/{tweet_id}" - response = requests.delete(url, headers=headers) + async with httpx.AsyncClient() as client: + response = await client.delete(url, headers=headers, timeout=10) if response.status_code != 200: raise ToolExecutionError( @@ -57,7 +59,7 @@ def delete_tweet_by_id( @tool(requires_auth=X(scopes=["tweet.read", "users.read"])) -def search_recent_tweets_by_username( +async def search_recent_tweets_by_username( context: ToolContext, username: Annotated[str, "The username of the X (Twitter) user to look up"], max_results: Annotated[ @@ -78,7 +80,8 @@ def search_recent_tweets_by_username( "https://api.x.com/2/tweets/search/recent?expansions=author_id&user.fields=id,name,username" ) - response = requests.get(url, headers=headers, params=params) + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, params=params, timeout=10) if response.status_code != 200: raise ToolExecutionError( @@ -91,10 +94,14 @@ def search_recent_tweets_by_username( @tool(requires_auth=X(scopes=["tweet.read", "users.read"])) -def search_recent_tweets_by_keywords( +async def search_recent_tweets_by_keywords( context: ToolContext, - keywords: Annotated[list[str], "List of keywords that must be present in the tweet"] = None, - phrases: Annotated[list[str], "List of phrases that must be present in the tweet"] = None, + keywords: Annotated[ + list[str] | None, "List of keywords that must be present in the tweet" + ] = None, + phrases: Annotated[ + list[str] | None, "List of phrases that must be present in the tweet" + ] = None, max_results: Annotated[ int, "The maximum number of results to return. Cannot be less than 10" ] = 10, @@ -125,7 +132,8 @@ def search_recent_tweets_by_keywords( "https://api.x.com/2/tweets/search/recent?expansions=author_id&user.fields=id,name,username" ) - response = requests.get(url, headers=headers, params=params) + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, params=params, timeout=10) if response.status_code != 200: raise ToolExecutionError( diff --git a/toolkits/x/arcade_x/tools/users.py b/toolkits/x/arcade_x/tools/users.py index c79d7425..def124cf 100644 --- a/toolkits/x/arcade_x/tools/users.py +++ b/toolkits/x/arcade_x/tools/users.py @@ -1,6 +1,6 @@ from typing import Annotated -import requests +import httpx from arcade.core.errors import ToolExecutionError from arcade.core.schema import ToolContext @@ -10,7 +10,7 @@ from arcade.sdk.auth import X # Users Lookup Tools. See developer docs for additional available query parameters: https://developer.x.com/en/docs/x-api/users/lookup/api-reference @tool(requires_auth=X(scopes=["users.read", "tweet.read"])) -def lookup_single_user_by_username( +async def lookup_single_user_by_username( context: ToolContext, username: Annotated[str, "The username of the X (Twitter) user to look up"], ) -> Annotated[str, "User information including id, name, username, and description"]: @@ -21,7 +21,8 @@ def lookup_single_user_by_username( } url = f"https://api.x.com/2/users/by/username/{username}?user.fields=created_at,description,id,location,most_recent_tweet_id,name,pinned_tweet_id,profile_image_url,protected,public_metrics,url,username,verified,verified_type,withheld" - response = requests.get(url, headers=headers) + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, timeout=10) if response.status_code != 200: raise ToolExecutionError( diff --git a/toolkits/x/arcade_x/tools/utils.py b/toolkits/x/arcade_x/tools/utils.py index d4f0e8b7..ee700e0c 100644 --- a/toolkits/x/arcade_x/tools/utils.py +++ b/toolkits/x/arcade_x/tools/utils.py @@ -53,6 +53,4 @@ def sanity_check_tweets_data(tweets_data: dict) -> bool: """ if not tweets_data.get("data", []): return False - if not tweets_data.get("includes", {}).get("users", []): - return False - return True + return tweets_data.get("includes", {}).get("users", []) diff --git a/toolkits/x/pyproject.toml b/toolkits/x/pyproject.toml index 86ac0762..83ef7510 100644 --- a/toolkits/x/pyproject.toml +++ b/toolkits/x/pyproject.toml @@ -7,6 +7,7 @@ authors = ["Eric Gustin "] [tool.poetry.dependencies] python = "^3.10" arcade-ai = "^0.1.0" +httpx = "^0.27.2" [tool.poetry.dev-dependencies] pytest = "^8.3.0"