arcade-mcp/toolkits/google_sheets/arcade_google_sheets/models.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

241 lines
7.4 KiB
Python

import json
from typing import Optional
from pydantic import BaseModel, field_validator, model_validator
from arcade_google_sheets.enums import CellErrorType, NumberFormatType
from arcade_google_sheets.types import CellValue
class CellErrorValue(BaseModel):
"""An error in a cell
Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorValue
"""
type: CellErrorType
message: str
class CellExtendedValue(BaseModel):
"""The kinds of value that a cell in a spreadsheet can have
Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ExtendedValue
"""
numberValue: float | None = None
stringValue: str | None = None
boolValue: bool | None = None
formulaValue: str | None = None
errorValue: Optional["CellErrorValue"] = None
@model_validator(mode="after")
def check_exactly_one_value(cls, instance): # type: ignore[no-untyped-def]
provided = [v for v in instance.__dict__.values() if v is not None]
if len(provided) != 1:
raise ValueError(
"Exactly one of numberValue, stringValue, boolValue, "
"formulaValue, or errorValue must be set."
)
return instance
class NumberFormat(BaseModel):
"""The format of a number
Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#NumberFormat
"""
pattern: str
type: NumberFormatType
class CellFormat(BaseModel):
"""The format of a cell
Partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat
"""
numberFormat: NumberFormat
class CellData(BaseModel):
"""Data about a specific cell
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellData
"""
userEnteredValue: CellExtendedValue
userEnteredFormat: CellFormat | None = None
class RowData(BaseModel):
"""Data about each cellin a row
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#RowData
"""
values: list[CellData]
class GridData(BaseModel):
"""Data in the grid
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridData
"""
startRow: int
startColumn: int
rowData: list[RowData]
class GridProperties(BaseModel):
"""Properties of a grid
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridProperties
"""
rowCount: int
columnCount: int
class SheetProperties(BaseModel):
"""Properties of a Sheet
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#SheetProperties
"""
sheetId: int
title: str
gridProperties: GridProperties | None = None
class Sheet(BaseModel):
"""A Sheet in a spreadsheet
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#Sheet
"""
properties: SheetProperties
data: list[GridData] | None = None
class SpreadsheetProperties(BaseModel):
"""Properties of a spreadsheet
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties
"""
title: str
class Spreadsheet(BaseModel):
"""A spreadsheet
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets
"""
properties: SpreadsheetProperties
sheets: list[Sheet]
class SheetDataInput(BaseModel):
"""
SheetDataInput models the cell data of a spreadsheet in a custom format.
It is a dictionary mapping row numbers (as ints) to dictionaries that map
column letters (as uppercase strings) to cell values (int, float, str, or bool).
This model enforces that:
- The outer keys are convertible to int.
- The inner keys are alphabetic strings (normalized to uppercase).
- All cell values are only of type int, float, str, or bool.
The model automatically serializes (via `json_data()`)
and validates the inner types.
"""
data: dict[int, dict[str, CellValue]]
@classmethod
def _parse_json_if_string(cls, value): # type: ignore[no-untyped-def]
"""Parses the value if it is a JSON string, otherwise returns it.
Helper method for when validating the `data` field.
"""
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError as e:
raise TypeError(f"Invalid JSON: {e}")
return value
@classmethod
def _validate_row_key(cls, row_key) -> int: # type: ignore[no-untyped-def]
"""Converts the row key to an integer, raising an error if conversion fails.
Helper method for when validating the `data` field.
"""
try:
return int(row_key)
except (ValueError, TypeError):
raise TypeError(f"Row key '{row_key}' is not convertible to int.")
@classmethod
def _validate_inner_cells(cls, cells, row_int: int) -> dict: # type: ignore[no-untyped-def]
"""Validates that 'cells' is a dict mapping column letters to valid cell values
and normalizes the keys.
Helper method for when validating the `data` field.
"""
if not isinstance(cells, dict):
raise TypeError(
f"Value for row '{row_int}' must be a dict mapping column letters to cell values."
)
new_inner = {}
for col_key, cell_value in cells.items():
if not isinstance(col_key, str):
raise TypeError(f"Column key '{col_key}' must be a string.")
col_string = col_key.upper()
if not col_string.isalpha():
raise TypeError(f"Column key '{col_key}' is invalid. Must be alphabetic.")
if not isinstance(cell_value, int | float | str | bool):
raise TypeError(
f"Cell value for {col_string}{row_int} must be an int, float, str, or bool."
)
new_inner[col_string] = cell_value
return new_inner
@field_validator("data", mode="before")
@classmethod
def validate_and_convert_keys(cls, value): # type: ignore[no-untyped-def]
"""
Validates data when SheetDataInput is instantiated and converts it to the correct format.
Uses private helper methods to parse JSON, validate row keys, and validate inner cell data.
"""
if value is None:
return {}
value = cls._parse_json_if_string(value)
if isinstance(value, dict):
new_value = {}
for row_key, cells in value.items():
row_int = cls._validate_row_key(row_key)
inner_cells = cls._validate_inner_cells(cells, row_int)
new_value[row_int] = inner_cells
return new_value
raise TypeError("data must be a dict or a valid JSON string representing a dict")
def json_data(self) -> str:
"""
Serialize the sheet data to a JSON string.
"""
return json.dumps(self.data)
@classmethod
def from_json(cls, json_str: str) -> "SheetDataInput":
"""
Create a SheetData instance from a JSON string.
"""
return cls.model_validate_json(json_str)