arcade-mcp/toolkits/google_sheets/arcade_google_sheets/utils.py
Eric Gustin 07c52100f3
Split and rename multiple toolkits (#438)
# PR Description
## Split toolkits

This PR splits the `Microsoft`, `Google`, and `Search` toolkits into
multiple toolkits each.
 * `Microsoft` --> `OutlookCalendar`, `OutlookMail`.
* `Google` -----> `GoogleCalendar`, `GoogleContacts`, `GoogleDocs`,
`GoogleDrive`, `Gmail`, `GoogleSheets`
* `Search` -----> `GoogleFinance`, `GoogleFlights`, `GoogleHotels`,
`GoogleJobs`, `GoogleMaps`, `GoogleNews`, `GoogleSearch`,
`GoogleShopping`, `Walmart`, `Youtube`

> The original monolithic toolkits (`Microsoft`, `Google`, `Search`) are
not removed in this PR. The plan is to keep those toolkits around while
we
> 1. Stop documenting the toolkits, 
> 2. Stop displaying the toolkits in the dashboard, and 
> 3. Help customers migrate over to the new split toolkits.

## Rename toolkits
This PR renames the following toolkits 
* `Web` ------------> `Firecrawl`
* `CodeSandbox` ---> `E2B`

> The `Web` and `CodeSandbox` toolkits are not removed in this PR. The
plan is to keep them around while we
> 1. Stop documenting the toolkits, 
> 2. Stop displaying the toolkits in the dashboard, and 
> 3. Help customers migrate over to the new renamed toolkits.

## Rename tools
Since toolkit names were changed, this called for some tools to be
renamed as well.
* `GoogleSearch.SearchGoogle` ----------------> `GoogleSearch.Search`
* `GoogleShopping.SearchShoppingProducts` --->
`GoogleShopping.SearchProducts`
* `Walmart.SearchWalmartProducts` ------------> `Walmart.SearchProducts`
* `Walmart.GetWalmartProductDetails` --------->
`Walmart.GetProductDetails`
* `Youtube.SearchYoutubeVideos` -------------->
`Youtube.SearchForVideos`

## Google File Picker
Improvements to the Google File Picker experience were also added in
this PR.

The following tools will ALWAYS provide llm_instructions in their
response to "let the end-user know that they have the option to select
more files via the file picker url if they want to":
* `GoogleDocs.SearchDocuments`
* `GoogleDocs.SearchAndRetrieveDocuments`
* `GoogleDrive.GetFileTreeStructure`

The following tools will only provide the file picker URL if a 404 or
403 from the Google API:
* `GoogleDocs.InsertTextAtEndOfDocument`
* `GoogleDocs.GetDocumentById`
* `GoogleSheets.GetSpreadsheet`
* `GoogleSheets.WriteToCell`

Also, a standalone `GoogleDrive.GenerateGoogleFilePickerUrl` tool
exists.

## Other
* The `SearchDocuments` and `SearchAndRetrieveDocuments` tools used to
be organized within the Drive portion of the Google toolkit, but I moved
these into the new GoogleDocs toolkit because they are specific to Docs.

# Progress

- [x] `OutlookCalendar`
- [x] `OutlookMail`
- [x] `GoogleFinance`
- [x] `GoogleFlights`
- [x] `GoogleHotels`
- [x] `GoogleJobs`
- [x] `GoogleMaps`
- [x] `GoogleNews`
- [x] `GoogleSearch`
- [x] `GoogleShopping`
- [x] `Walmart`
- [x] `Youtube`
- [x] `GoogleCalendar`
- [x] `GoogleContacts`
- [x] `GoogleDocs`
- [x] `GoogleDrive`
- [x] `Gmail`
- [x] `GoogleSheets`
- [x] `Firecrawl`
- [x] `E2B`
- [x] File picker

# Discussion
* Repeated code is a consequence of splitting toolkits that use the same
provider. I am open to any ideas that would allow multiple toolkits to
reference common code. Comment your ideas in this PR.
2025-07-09 16:00:09 -07:00

548 lines
18 KiB
Python

import logging
from typing import Any
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from arcade_google_sheets.constants import (
DEFAULT_SHEET_COLUMN_COUNT,
DEFAULT_SHEET_ROW_COUNT,
)
from arcade_google_sheets.enums import NumberFormatType
from arcade_google_sheets.models import (
CellData,
CellExtendedValue,
CellFormat,
GridData,
GridProperties,
NumberFormat,
RowData,
Sheet,
SheetDataInput,
SheetProperties,
)
from arcade_google_sheets.types import CellValue
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def build_sheets_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
"""
Build a Sheets service object.
"""
auth_token = auth_token or ""
return build("sheets", "v4", credentials=Credentials(auth_token))
def col_to_index(col: str) -> int:
"""Convert a sheet's column string to a 0-indexed column index
Args:
col (str): The column string to convert. e.g., "A", "AZ", "QED"
Returns:
int: The 0-indexed column index.
"""
result = 0
for char in col.upper():
result = result * 26 + (ord(char) - ord("A") + 1)
return result - 1
def index_to_col(index: int) -> str:
"""Convert a 0-indexed column index to its corresponding column string
Args:
index (int): The 0-indexed column index to convert.
Returns:
str: The column string. e.g., "A", "AZ", "QED"
"""
result = ""
index += 1
while index > 0:
index, rem = divmod(index - 1, 26)
result = chr(rem + ord("A")) + result
return result
def is_col_greater(col1: str, col2: str) -> bool:
"""Determine if col1 represents a column that comes after col2 in a sheet
This comparison is based on:
1. The length of the column string (longer means greater).
2. Lexicographical comparison if both strings are the same length.
Args:
col1 (str): The first column string to compare.
col2 (str): The second column string to compare.
Returns:
bool: True if col1 comes after col2, False otherwise.
"""
if len(col1) != len(col2):
return len(col1) > len(col2)
return col1.upper() > col2.upper()
def compute_sheet_data_dimensions(
sheet_data_input: SheetDataInput,
) -> tuple[tuple[int, int], tuple[int, int]]:
"""
Compute the dimensions of a sheet based on the data provided.
Args:
sheet_data_input (SheetDataInput):
The data to compute the dimensions of.
Returns:
tuple[tuple[int, int], tuple[int, int]]: The dimensions of the sheet. The first tuple
contains the row range (start, end) and the second tuple contains the column range
(start, end).
"""
max_row = 0
min_row = 10_000_000 # max number of cells in a sheet
max_col_str = None
min_col_str = None
for key, row in sheet_data_input.data.items():
try:
row_num = int(key)
except ValueError:
continue
if row_num > max_row:
max_row = row_num
if row_num < min_row:
min_row = row_num
if isinstance(row, dict):
for col in row:
# Update max column string
if max_col_str is None or is_col_greater(col, max_col_str):
max_col_str = col
# Update min column string
if min_col_str is None or is_col_greater(min_col_str, col):
min_col_str = col
max_col_index = col_to_index(max_col_str) if max_col_str is not None else -1
min_col_index = col_to_index(min_col_str) if min_col_str is not None else 0
return (min_row, max_row), (min_col_index, max_col_index)
def create_sheet(sheet_data_input: SheetDataInput) -> Sheet:
"""Create a Google Sheet from a dictionary of data.
Args:
sheet_data_input (SheetDataInput): The data to create the sheet from.
Returns:
Sheet: The created sheet.
"""
(_, max_row), (min_col_index, max_col_index) = compute_sheet_data_dimensions(sheet_data_input)
sheet_data = create_sheet_data(sheet_data_input, min_col_index, max_col_index)
sheet_properties = create_sheet_properties(
row_count=max(DEFAULT_SHEET_ROW_COUNT, max_row),
column_count=max(DEFAULT_SHEET_COLUMN_COUNT, max_col_index + 1),
)
return Sheet(properties=sheet_properties, data=sheet_data)
def create_sheet_properties(
sheet_id: int = 1,
title: str = "Sheet1",
row_count: int = DEFAULT_SHEET_ROW_COUNT,
column_count: int = DEFAULT_SHEET_COLUMN_COUNT,
) -> SheetProperties:
"""Create a SheetProperties object
Args:
sheet_id (int): The ID of the sheet.
title (str): The title of the sheet.
row_count (int): The number of rows in the sheet.
column_count (int): The number of columns in the sheet.
Returns:
SheetProperties: The created sheet properties object.
"""
return SheetProperties(
sheetId=sheet_id,
title=title,
gridProperties=GridProperties(rowCount=row_count, columnCount=column_count),
)
def group_contiguous_rows(row_numbers: list[int]) -> list[list[int]]:
"""Groups a sorted list of row numbers into contiguous groups
A contiguous group is a list of row numbers that are consecutive integers.
For example, [1,2,3,5,6] is converted to [[1,2,3],[5,6]].
Args:
row_numbers (list[int]): The list of row numbers to group.
Returns:
list[list[int]]: The grouped row numbers.
"""
if not row_numbers:
return []
groups = []
current_group = [row_numbers[0]]
for r in row_numbers[1:]:
if r == current_group[-1] + 1:
current_group.append(r)
else:
groups.append(current_group)
current_group = [r]
groups.append(current_group)
return groups
def create_cell_data(cell_value: CellValue) -> CellData:
"""
Create a CellData object based on the type of cell_value.
"""
if isinstance(cell_value, bool):
return _create_bool_cell(cell_value)
elif isinstance(cell_value, int):
return _create_int_cell(cell_value)
elif isinstance(cell_value, float):
return _create_float_cell(cell_value)
elif isinstance(cell_value, str):
return _create_string_cell(cell_value)
def _create_formula_cell(cell_value: str) -> CellData:
cell_val = CellExtendedValue(formulaValue=cell_value)
return CellData(userEnteredValue=cell_val)
def _create_currency_cell(cell_value: str) -> CellData:
value_without_symbol = cell_value[1:]
try:
num_value = int(value_without_symbol)
cell_format = CellFormat(
numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0")
)
cell_val = CellExtendedValue(numberValue=num_value)
return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
except ValueError:
try:
num_value = float(value_without_symbol) # type: ignore[assignment]
cell_format = CellFormat(
numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0.00")
)
cell_val = CellExtendedValue(numberValue=num_value)
return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
except ValueError:
return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
def _create_percent_cell(cell_value: str) -> CellData:
try:
num_value = float(cell_value[:-1].strip())
cell_format = CellFormat(
numberFormat=NumberFormat(type=NumberFormatType.PERCENT, pattern="0.00%")
)
cell_val = CellExtendedValue(numberValue=num_value)
return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
except ValueError:
return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
def _create_bool_cell(cell_value: bool) -> CellData:
return CellData(userEnteredValue=CellExtendedValue(boolValue=cell_value))
def _create_int_cell(cell_value: int) -> CellData:
cell_format = CellFormat(
numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0")
)
return CellData(
userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format
)
def _create_float_cell(cell_value: float) -> CellData:
cell_format = CellFormat(
numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0.00")
)
return CellData(
userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format
)
def _create_string_cell(cell_value: str) -> CellData:
if cell_value.startswith("="):
return _create_formula_cell(cell_value)
elif cell_value.startswith("$") and len(cell_value) > 1:
return _create_currency_cell(cell_value)
elif cell_value.endswith("%") and len(cell_value) > 1:
return _create_percent_cell(cell_value)
return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
def create_row_data(
row_data: dict[str, CellValue], min_col_index: int, max_col_index: int
) -> RowData:
"""Constructs RowData for a single row using the provided row_data.
Args:
row_data (dict[str, CellValue]): The data to create the row from.
min_col_index (int): The minimum column index from the SheetDataInput.
max_col_index (int): The maximum column index from the SheetDataInput.
"""
row_cells = []
for col_idx in range(min_col_index, max_col_index + 1):
col_letter = index_to_col(col_idx)
if col_letter in row_data:
cell_data = create_cell_data(row_data[col_letter])
else:
cell_data = CellData(userEnteredValue=CellExtendedValue(stringValue=""))
row_cells.append(cell_data)
return RowData(values=row_cells)
def create_sheet_data(
sheet_data_input: SheetDataInput,
min_col_index: int,
max_col_index: int,
) -> list[GridData]:
"""Create grid data from SheetDataInput by grouping contiguous rows and processing cells.
Args:
sheet_data_input (SheetDataInput): The data to create the sheet from.
min_col_index (int): The minimum column index from the SheetDataInput.
max_col_index (int): The maximum column index from the SheetDataInput.
Returns:
list[GridData]: The created grid data.
"""
row_numbers = list(sheet_data_input.data.keys())
if not row_numbers:
return []
sorted_rows = sorted(row_numbers)
groups = group_contiguous_rows(sorted_rows)
sheet_data = []
for group in groups:
rows_data = []
for r in group:
current_row_data = sheet_data_input.data.get(r, {})
row = create_row_data(current_row_data, min_col_index, max_col_index)
rows_data.append(row)
grid_data = GridData(
startRow=group[0] - 1, # convert to 0-indexed
startColumn=min_col_index,
rowData=rows_data,
)
sheet_data.append(grid_data)
return sheet_data
def parse_get_spreadsheet_response(api_response: dict) -> dict:
"""
Parse the get spreadsheet Google Sheets API response into a structured dictionary.
"""
properties = api_response.get("properties", {})
sheets = [parse_sheet(sheet) for sheet in api_response.get("sheets", [])]
return {
"title": properties.get("title", ""),
"spreadsheetId": api_response.get("spreadsheetId", ""),
"spreadsheetUrl": api_response.get("spreadsheetUrl", ""),
"sheets": sheets,
}
def parse_sheet(api_sheet: dict) -> dict:
"""
Parse an individual sheet's data from the Google Sheets 'get spreadsheet'
API response into a structured dictionary.
"""
props = api_sheet.get("properties", {})
grid_props = props.get("gridProperties", {})
cell_data = convert_api_grid_data_to_dict(api_sheet.get("data", []))
return {
"sheetId": props.get("sheetId"),
"title": props.get("title", ""),
"rowCount": grid_props.get("rowCount", 0),
"columnCount": grid_props.get("columnCount", 0),
"data": cell_data,
}
def extract_user_entered_cell_value(cell: dict) -> Any:
"""
Extract the user entered value from a cell's 'userEnteredValue'.
Args:
cell (dict): A cell dictionary from the grid data.
Returns:
The extracted value if present, otherwise None.
"""
user_val = cell.get("userEnteredValue", {})
for key in ["stringValue", "numberValue", "boolValue", "formulaValue"]:
if key in user_val:
return user_val[key]
return ""
def process_row(row: dict, start_column_index: int) -> dict:
"""
Process a single row from grid data, converting non-empty cells into a dictionary
that maps column letters to cell values.
Args:
row (dict): A row from the grid data.
start_column_index (int): The starting column index for this row.
Returns:
dict: A mapping of column letters to cell values for non-empty cells.
"""
row_result = {}
for j, cell in enumerate(row.get("values", [])):
column_index = start_column_index + j
column_string = index_to_col(column_index)
user_entered_cell_value = extract_user_entered_cell_value(cell)
formatted_cell_value = cell.get("formattedValue", "")
if user_entered_cell_value != "" or formatted_cell_value != "":
row_result[column_string] = {
"userEnteredValue": user_entered_cell_value,
"formattedValue": formatted_cell_value,
}
return row_result
def convert_api_grid_data_to_dict(grids: list[dict]) -> dict:
"""
Convert a list of grid data dictionaries from the 'get spreadsheet' API
response into a structured cell dictionary.
The returned dictionary maps row numbers to sub-dictionaries that map column letters
(e.g., 'A', 'B', etc.) to their corresponding non-empty cell values.
Args:
grids (list[dict]): The list of grid data dictionaries from the API.
Returns:
dict: A dictionary mapping row numbers to dictionaries of column letter/value pairs.
Only includes non-empty rows and non-empty cells.
"""
result = {}
for grid in grids:
start_row = grid.get("startRow", 0)
start_column = grid.get("startColumn", 0)
for i, row in enumerate(grid.get("rowData", []), start=1):
current_row = start_row + i
row_data = process_row(row, start_column)
if row_data:
result[current_row] = row_data
return dict(sorted(result.items()))
def validate_write_to_cell_params( # type: ignore[no-any-unimported]
service: Resource,
spreadsheet_id: str,
sheet_name: str,
column: str,
row: int,
) -> None:
"""Validates the input parameters for the write to cell tool.
Args:
service (Resource): The Google Sheets service.
spreadsheet_id (str): The ID of the spreadsheet provided to the tool.
sheet_name (str): The name of the sheet provided to the tool.
column (str): The column to write to provided to the tool.
row (int): The row to write to provided to the tool.
Raises:
RetryableToolError:
If the sheet name is not found in the spreadsheet
ToolExecutionError:
If the column is not alphabetical
If the row is not a positive number
If the row is out of bounds for the sheet
If the column is out of bounds for the sheet
"""
if not column.isalpha():
raise ToolExecutionError(
message=(
f"Invalid column name {column}. "
"It must be a non-empty string containing only letters"
),
)
if row < 1:
raise ToolExecutionError(
message=(f"Invalid row number {row}. It must be a positive integer greater than 0."),
)
sheet_properties = (
service.spreadsheets()
.get(
spreadsheetId=spreadsheet_id,
includeGridData=True,
fields="sheets/properties/title,sheets/properties/gridProperties/rowCount,sheets/properties/gridProperties/columnCount",
)
.execute()
)
sheet_names = [sheet["properties"]["title"] for sheet in sheet_properties["sheets"]]
sheet_row_count = sheet_properties["sheets"][0]["properties"]["gridProperties"]["rowCount"]
sheet_column_count = sheet_properties["sheets"][0]["properties"]["gridProperties"][
"columnCount"
]
if sheet_name not in sheet_names:
raise RetryableToolError(
message=f"Sheet name {sheet_name} not found in spreadsheet with id {spreadsheet_id}",
additional_prompt_content=f"Sheet names in the spreadsheet: {sheet_names}",
retry_after_ms=100,
)
if row > sheet_row_count:
raise ToolExecutionError(
message=(
f"Row {row} is out of bounds for sheet {sheet_name} "
f"in spreadsheet with id {spreadsheet_id}. "
f"Sheet only has {sheet_row_count} rows which is less than the requested row {row}"
)
)
if col_to_index(column) > sheet_column_count:
raise ToolExecutionError(
message=(
f"Column {column} is out of bounds for sheet {sheet_name} "
f"in spreadsheet with id {spreadsheet_id}. "
f"Sheet only has {sheet_column_count} columns which "
f"is less than the requested column {column}"
)
)
def parse_write_to_cell_response(response: dict) -> dict:
return {
"spreadsheetId": response["spreadsheetId"],
"sheetTitle": response["updatedData"]["range"].split("!")[0],
"updatedCell": response["updatedData"]["range"].split("!")[1],
"value": response["updatedData"]["values"][0][0],
}