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

View file

@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
from bs4 import BeautifulSoup
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError
from arcade_google.tools.models import Day, TimeSlot
@ -72,6 +73,18 @@ class DateRange(Enum):
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]:
"""
Parse email data and extract relevant information.

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "arcade_google"
version = "0.1.9"
version = "0.1.10"
description = "Arcade tools for the entire google suite"
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
import httpx
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import LinkedIn
from arcade.sdk.errors import ToolExecutionError
LINKEDIN_BASE_URL = "https://api.linkedin.com/v2"
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}")
from arcade_linkedin.tools.utils import _handle_linkedin_api_error, _send_linkedin_request
@tool(
@ -90,6 +26,7 @@ async def create_text_post(
# 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
user_id = context.authorization.user_info.get("sub") if context.authorization else None
if not user_id:
raise ToolExecutionError(
"User ID not found.",
@ -110,9 +47,11 @@ async def create_text_post(
}
response = await _send_linkedin_request(context, "POST", endpoint, json_data=payload)
if response.status_code >= 200 and response.status_code < 300:
share_id = response.json().get("id")
return f"https://www.linkedin.com/feed/update/{share_id}/"
_handle_linkedin_api_error(response)
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]
name = "arcade_linkedin"
version = "0.1.8"
version = "0.1.9"
description = "Arcade tools for LinkedIn"
authors = ["Arcade AI <dev@arcade-ai.com>"]

View file

@ -1,10 +1,11 @@
import json
import os
from typing import Annotated, Any, Optional
from typing import Annotated
import serpapi
from arcade.sdk import tool
from arcade_search.tools.utils import get_secret
@tool
async def search_google(
@ -25,12 +26,3 @@ async def search_google(
organic_results = results.get("organic_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]
name = "arcade_search"
version = "0.1.8"
version = "0.1.9"
description = "Tools for searching the web"
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.errors import RetryableToolError
from arcade_x.tools.constants import TWEETS_URL
from arcade_x.tools.utils import (
expand_attached_media,
expand_long_tweet,
@ -15,9 +16,6 @@ from arcade_x.tools.utils import (
remove_none_values,
)
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

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "arcade_x"
version = "0.1.9"
version = "0.1.10"
description = "LLM tools for interacting with X (Twitter)"
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
import httpx
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import Zoom
from arcade.sdk.errors import ToolExecutionError
ZOOM_BASE_URL = "https://api.zoom.us/v2"
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}")
from arcade_zoom.tools.utils import _handle_zoom_api_error, _send_zoom_request
@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]
name = "arcade_zoom"
version = "0.1.8"
version = "0.1.9"
description = "Arcade tools for Zoom"
authors = ["Arcade AI <dev@arcade-ai.com>"]