Separate tools & helper funcs in separate files (#192)

Separates utility and helper functions, as well as constant values (e.g.
base URLs), in dedicated files, apart from tools files.
This commit is contained in:
Renato Byrro 2025-01-08 16:39:25 -03:00 committed by GitHub
parent cd1fb648bd
commit cd837a363d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 179 additions and 164 deletions

View file

@ -8,7 +8,6 @@ from arcade.sdk.auth import Google
from arcade.sdk.errors import RetryableToolError from arcade.sdk.errors import RetryableToolError
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from arcade_google.tools.utils import ( from arcade_google.tools.utils import (
DateRange, DateRange,
@ -19,6 +18,7 @@ from arcade_google.tools.utils import (
get_sent_email_url, get_sent_email_url,
parse_draft_email, parse_draft_email,
parse_email, parse_email,
process_email_messages,
remove_none_values, remove_none_values,
) )
@ -356,22 +356,10 @@ async def list_emails_by_header(
if not messages: if not messages:
return {"emails": []} return {"emails": []}
emails = process_messages(service, messages) emails = process_email_messages(service, messages)
return {"emails": emails} return {"emails": emails}
def process_messages(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
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( @tool(
requires_auth=Google( requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.readonly"], scopes=["https://www.googleapis.com/auth/gmail.readonly"],

View file

@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError
from arcade_google.tools.models import Day, TimeSlot from arcade_google.tools.models import Day, TimeSlot
@ -72,6 +73,18 @@ class DateRange(Enum):
return result + comparison_date.strftime("%Y/%m/%d") return result + comparison_date.strftime("%Y/%m/%d")
def process_email_messages(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
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
def parse_email(email_data: dict[str, Any]) -> dict[str, Any]: def parse_email(email_data: dict[str, Any]) -> dict[str, Any]:
""" """
Parse email data and extract relevant information. Parse email data and extract relevant information.

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "arcade_google" name = "arcade_google"
version = "0.1.9" version = "0.1.10"
description = "Arcade tools for the entire google suite" description = "Arcade tools for the entire google suite"
authors = ["Arcade AI <dev@arcade-ai.com>"] authors = ["Arcade AI <dev@arcade-ai.com>"]

View file

@ -0,0 +1 @@
LINKEDIN_BASE_URL = "https://api.linkedin.com/v2"

View file

@ -1,74 +1,10 @@
from typing import Annotated from typing import Annotated
import httpx
from arcade.sdk import ToolContext, tool from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import LinkedIn from arcade.sdk.auth import LinkedIn
from arcade.sdk.errors import ToolExecutionError from arcade.sdk.errors import ToolExecutionError
LINKEDIN_BASE_URL = "https://api.linkedin.com/v2" from arcade_linkedin.tools.utils import _handle_linkedin_api_error, _send_linkedin_request
async def _send_linkedin_request(
context: ToolContext,
method: str,
endpoint: str,
params: dict | None = None,
json_data: dict | None = None,
) -> httpx.Response:
"""
Send an asynchronous request to the LinkedIn API.
Args:
context: The tool context containing the authorization token.
method: The HTTP method (GET, POST, PUT, DELETE, etc.).
endpoint: The API endpoint path (e.g., "/ugcPosts").
params: Query parameters to include in the request.
json_data: JSON data to include in the request body.
Returns:
The response object from the API request.
Raises:
ToolExecutionError: If the request fails for any reason.
"""
url = f"{LINKEDIN_BASE_URL}{endpoint}"
token = (
context.authorization.token if context.authorization and context.authorization.token else ""
)
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient() as client:
try:
response = await client.request(
method, url, headers=headers, params=params, json=json_data
)
response.raise_for_status()
except httpx.RequestError as e:
raise ToolExecutionError(f"Failed to send request to LinkedIn API: {e}")
return response
def _handle_linkedin_api_error(response: httpx.Response) -> None:
"""
Handle errors from the LinkedIn API by mapping common status codes to ToolExecutionErrors.
Args:
response: The response object from the API request.
Raises:
ToolExecutionError: If the response contains an error status code.
"""
status_code_map = {
401: ToolExecutionError("Unauthorized: Invalid or expired token"),
403: ToolExecutionError("Forbidden: User does not have Spotify Premium"),
429: ToolExecutionError("Too Many Requests: Rate limit exceeded"),
}
if response.status_code in status_code_map:
raise status_code_map[response.status_code]
elif response.status_code >= 400:
raise ToolExecutionError(f"Error: {response.status_code} - {response.text}")
@tool( @tool(
@ -90,6 +26,7 @@ async def create_text_post(
# LinkedIn calls the user ID "sub" in their user_info data payload. See: # LinkedIn calls the user ID "sub" in their user_info data payload. See:
# https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#api-request-to-retreive-member-details # https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#api-request-to-retreive-member-details
user_id = context.authorization.user_info.get("sub") if context.authorization else None user_id = context.authorization.user_info.get("sub") if context.authorization else None
if not user_id: if not user_id:
raise ToolExecutionError( raise ToolExecutionError(
"User ID not found.", "User ID not found.",
@ -110,9 +47,11 @@ async def create_text_post(
} }
response = await _send_linkedin_request(context, "POST", endpoint, json_data=payload) response = await _send_linkedin_request(context, "POST", endpoint, json_data=payload)
if response.status_code >= 200 and response.status_code < 300: if response.status_code >= 200 and response.status_code < 300:
share_id = response.json().get("id") share_id = response.json().get("id")
return f"https://www.linkedin.com/feed/update/{share_id}/" return f"https://www.linkedin.com/feed/update/{share_id}/"
_handle_linkedin_api_error(response) _handle_linkedin_api_error(response)
return "" return ""

View file

@ -0,0 +1,68 @@
import httpx
from arcade.sdk import ToolContext
from arcade.sdk.errors import ToolExecutionError
from arcade_linkedin.tools.constants import LINKEDIN_BASE_URL
async def _send_linkedin_request(
context: ToolContext,
method: str,
endpoint: str,
params: dict | None = None,
json_data: dict | None = None,
) -> httpx.Response:
"""
Send an asynchronous request to the LinkedIn API.
Args:
context: The tool context containing the authorization token.
method: The HTTP method (GET, POST, PUT, DELETE, etc.).
endpoint: The API endpoint path (e.g., "/ugcPosts").
params: Query parameters to include in the request.
json_data: JSON data to include in the request body.
Returns:
The response object from the API request.
Raises:
ToolExecutionError: If the request fails for any reason.
"""
url = f"{LINKEDIN_BASE_URL}{endpoint}"
token = (
context.authorization.token if context.authorization and context.authorization.token else ""
)
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient() as client:
try:
response = await client.request(
method, url, headers=headers, params=params, json=json_data
)
response.raise_for_status()
except httpx.RequestError as e:
raise ToolExecutionError(f"Failed to send request to LinkedIn API: {e}")
return response
def _handle_linkedin_api_error(response: httpx.Response) -> None:
"""
Handle errors from the LinkedIn API by mapping common status codes to ToolExecutionErrors.
Args:
response: The response object from the API request.
Raises:
ToolExecutionError: If the response contains an error status code.
"""
status_code_map = {
401: ToolExecutionError("Unauthorized: Invalid or expired token"),
403: ToolExecutionError("Forbidden: User does not have Spotify Premium"),
429: ToolExecutionError("Too Many Requests: Rate limit exceeded"),
}
if response.status_code in status_code_map:
raise status_code_map[response.status_code]
elif response.status_code >= 400:
raise ToolExecutionError(f"Error: {response.status_code} - {response.text}")

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "arcade_linkedin" name = "arcade_linkedin"
version = "0.1.8" version = "0.1.9"
description = "Arcade tools for LinkedIn" description = "Arcade tools for LinkedIn"
authors = ["Arcade AI <dev@arcade-ai.com>"] authors = ["Arcade AI <dev@arcade-ai.com>"]

View file

@ -1,10 +1,11 @@
import json import json
import os from typing import Annotated
from typing import Annotated, Any, Optional
import serpapi import serpapi
from arcade.sdk import tool from arcade.sdk import tool
from arcade_search.tools.utils import get_secret
@tool @tool
async def search_google( async def search_google(
@ -25,12 +26,3 @@ async def search_google(
organic_results = results.get("organic_results", []) organic_results = results.get("organic_results", [])
return json.dumps(organic_results[:n_results]) return json.dumps(organic_results[:n_results])
def get_secret(name: str, default: Optional[Any] = None) -> Any:
secret = os.getenv(name)
if secret is None:
if default is not None:
return default
raise ValueError(f"Secret {name} is not set.")
return secret

View file

@ -0,0 +1,11 @@
import os
from typing import Any, Optional
def get_secret(name: str, default: Optional[Any] = None) -> Any:
secret = os.getenv(name)
if secret is None:
if default is not None:
return default
raise ValueError(f"Secret {name} is not set.")
return secret

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "arcade_search" name = "arcade_search"
version = "0.1.8" version = "0.1.9"
description = "Tools for searching the web" description = "Tools for searching the web"
authors = ["Arcade AI <dev@arcade-ai.com>"] authors = ["Arcade AI <dev@arcade-ai.com>"]

View file

@ -0,0 +1 @@
TWEETS_URL = "https://api.x.com/2/tweets"

View file

@ -5,6 +5,7 @@ from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import X from arcade.sdk.auth import X
from arcade.sdk.errors import RetryableToolError from arcade.sdk.errors import RetryableToolError
from arcade_x.tools.constants import TWEETS_URL
from arcade_x.tools.utils import ( from arcade_x.tools.utils import (
expand_attached_media, expand_attached_media,
expand_long_tweet, expand_long_tweet,
@ -15,9 +16,6 @@ from arcade_x.tools.utils import (
remove_none_values, remove_none_values,
) )
TWEETS_URL = "https://api.x.com/2/tweets"
# Manage Tweets Tools. See developer docs for additional available parameters: # Manage Tweets Tools. See developer docs for additional available parameters:
# https://developer.x.com/en/docs/x-api/tweets/manage-tweets/api-reference # https://developer.x.com/en/docs/x-api/tweets/manage-tweets/api-reference

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "arcade_x" name = "arcade_x"
version = "0.1.9" version = "0.1.10"
description = "LLM tools for interacting with X (Twitter)" description = "LLM tools for interacting with X (Twitter)"
authors = ["Arcade AI <dev@arcade-ai.com>"] authors = ["Arcade AI <dev@arcade-ai.com>"]

View file

@ -0,0 +1 @@
ZOOM_BASE_URL = "https://api.zoom.us/v2"

View file

@ -1,74 +1,9 @@
from typing import Annotated, Optional from typing import Annotated, Optional
import httpx
from arcade.sdk import ToolContext, tool from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import Zoom from arcade.sdk.auth import Zoom
from arcade.sdk.errors import ToolExecutionError
ZOOM_BASE_URL = "https://api.zoom.us/v2" from arcade_zoom.tools.utils import _handle_zoom_api_error, _send_zoom_request
async def _send_zoom_request(
context: ToolContext,
method: str,
endpoint: str,
params: dict | None = None,
json_data: dict | None = None,
) -> httpx.Response:
"""
Send an asynchronous request to the Zoom API.
Args:
context: The tool context containing the authorization token.
method: The HTTP method (GET, POST, PUT, DELETE, etc.).
endpoint: The API endpoint path (e.g., "/users/me/upcoming_meetings").
params: Query parameters to include in the request.
json_data: JSON data to include in the request body.
Returns:
The response object from the API request.
Raises:
ToolExecutionError: If the request fails for any reason.
"""
url = f"{ZOOM_BASE_URL}{endpoint}"
token = (
context.authorization.token if context.authorization and context.authorization.token else ""
)
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient() as client:
try:
response = await client.request(
method, url, headers=headers, params=params, json=json_data
)
response.raise_for_status()
except httpx.RequestError as e:
raise ToolExecutionError(f"Failed to send request to Zoom API: {e}")
return response
def _handle_zoom_api_error(response: httpx.Response) -> None:
"""
Handle errors from the Zoom API by mapping common status codes to ToolExecutionErrors.
Args:
response: The response object from the API request.
Raises:
ToolExecutionError: If the response contains an error status code.
"""
status_code_map = {
401: ToolExecutionError("Unauthorized: Invalid or expired token"),
403: ToolExecutionError("Forbidden: Access denied"),
429: ToolExecutionError("Too Many Requests: Rate limit exceeded"),
}
if response.status_code in status_code_map:
raise status_code_map[response.status_code]
elif response.status_code >= 400:
raise ToolExecutionError(f"Error: {response.status_code} - {response.text}")
@tool( @tool(

View file

@ -0,0 +1,68 @@
import httpx
from arcade.sdk import ToolContext
from arcade.sdk.errors import ToolExecutionError
from arcade_zoom.tools.constants import ZOOM_BASE_URL
async def _send_zoom_request(
context: ToolContext,
method: str,
endpoint: str,
params: dict | None = None,
json_data: dict | None = None,
) -> httpx.Response:
"""
Send an asynchronous request to the Zoom API.
Args:
context: The tool context containing the authorization token.
method: The HTTP method (GET, POST, PUT, DELETE, etc.).
endpoint: The API endpoint path (e.g., "/users/me/upcoming_meetings").
params: Query parameters to include in the request.
json_data: JSON data to include in the request body.
Returns:
The response object from the API request.
Raises:
ToolExecutionError: If the request fails for any reason.
"""
url = f"{ZOOM_BASE_URL}{endpoint}"
token = (
context.authorization.token if context.authorization and context.authorization.token else ""
)
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient() as client:
try:
response = await client.request(
method, url, headers=headers, params=params, json=json_data
)
response.raise_for_status()
except httpx.RequestError as e:
raise ToolExecutionError(f"Failed to send request to Zoom API: {e}")
return response
def _handle_zoom_api_error(response: httpx.Response) -> None:
"""
Handle errors from the Zoom API by mapping common status codes to ToolExecutionErrors.
Args:
response: The response object from the API request.
Raises:
ToolExecutionError: If the response contains an error status code.
"""
status_code_map = {
401: ToolExecutionError("Unauthorized: Invalid or expired token"),
403: ToolExecutionError("Forbidden: Access denied"),
429: ToolExecutionError("Too Many Requests: Rate limit exceeded"),
}
if response.status_code in status_code_map:
raise status_code_map[response.status_code]
elif response.status_code >= 400:
raise ToolExecutionError(f"Error: {response.status_code} - {response.text}")

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "arcade_zoom" name = "arcade_zoom"
version = "0.1.8" version = "0.1.9"
description = "Arcade tools for Zoom" description = "Arcade tools for Zoom"
authors = ["Arcade AI <dev@arcade-ai.com>"] authors = ["Arcade AI <dev@arcade-ai.com>"]