Google Sheets Tools (#321)
| Name | Description |
|--------------------------|---------------------------------------------------------------------------------------|
| Google.CreateSpreadsheet | Create a new spreadsheet with the provided
title and data in its first sheet |
| Google.GetSpreadsheet | Get the user entered and formatted data for
all sheets in the spreadsheet |
| Google.WriteToCell | Write a value to a single cell in a spreadsheet.
|
## Google.CreateSpreadsheet
This tool can create a new spreadsheet with data in its first sheet
This tool takes in the data as a JSON string. Here's an example input:
```
// Good at large payloads, sparse payloads, and contiguous data payloads.
// For example data[1]["D"] represents the value of the cell in the first row in the D column
{
// All data in row 1
1: {
"A": 42,
"B": 2,
"D":"=A1+B1"
},
// All data in row 54
54: {
"A": "my string",
"QQ": "my far away string"
}
}
```
The above data format performed better on evals than the other two that
I tested:
```
// Performed poorly at sparse data and also at larger amounts of data
[
[42, 2, "", "=A1+B1"],
[],
[],
...,
["A": "my string", "", "", ..., "my far away string"]
]
```
```
// Good at small payloads and sparse payloads, but very bad at payloads with contiguous data
{
"A1": 42", "B1": 2, "D1": "=A1+B1", "A54": "my string", "QQ": "my far away string"
}
```
## Google.GetSpreadsheet
Gets the formatted values for all non empty cells in all sheets of the
spreadsheet. The data returned is in a similar format as the
`Google.CreateSpreadsheet` tool's `data` input parameter. The difference
is that `get_spreadsheet` will return the user entered value (=A1+B1)
and also the formatted value (23.4) for each cell.
## Google.WriteToCell
Writes to a single cell. At this point in time we do not support batch
updating a sheet.
This commit is contained in:
parent
e26f647c3d
commit
04bda3cc45
8 changed files with 1757 additions and 2 deletions
|
|
@ -19,3 +19,6 @@ except ValueError as e:
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_SEARCH_CONTACTS_LIMIT = 30
|
DEFAULT_SEARCH_CONTACTS_LIMIT = 30
|
||||||
|
|
||||||
|
DEFAULT_SHEET_ROW_COUNT = 1000
|
||||||
|
DEFAULT_SHEET_COLUMN_COUNT = 26
|
||||||
|
|
|
||||||
0
toolkits/google/arcade_google/enums.py
Normal file
0
toolkits/google/arcade_google/enums.py
Normal file
|
|
@ -1,7 +1,11 @@
|
||||||
|
import json
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------------- #
|
||||||
# Google Calendar Models and Enums
|
# Google Calendar Models and Enums
|
||||||
|
|
@ -361,3 +365,267 @@ class GmailReplyToWhom(str, Enum):
|
||||||
class GmailAction(str, Enum):
|
class GmailAction(str, Enum):
|
||||||
SEND = "send"
|
SEND = "send"
|
||||||
DRAFT = "draft"
|
DRAFT = "draft"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
# Google Sheets Models and Enums
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
class CellErrorType(str, Enum):
|
||||||
|
"""The type of error in a cell
|
||||||
|
|
||||||
|
Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorType
|
||||||
|
"""
|
||||||
|
|
||||||
|
ERROR_TYPE_UNSPECIFIED = "ERROR_TYPE_UNSPECIFIED" # The default error type, do not use this.
|
||||||
|
ERROR = "ERROR" # Corresponds to the #ERROR! error.
|
||||||
|
NULL_VALUE = "NULL_VALUE" # Corresponds to the #NULL! error.
|
||||||
|
DIVIDE_BY_ZERO = "DIVIDE_BY_ZERO" # Corresponds to the #DIV/0 error.
|
||||||
|
VALUE = "VALUE" # Corresponds to the #VALUE! error.
|
||||||
|
REF = "REF" # Corresponds to the #REF! error.
|
||||||
|
NAME = "NAME" # Corresponds to the #NAME? error.
|
||||||
|
NUM = "NUM" # Corresponds to the #NUM! error.
|
||||||
|
N_A = "N_A" # Corresponds to the #N/A error.
|
||||||
|
LOADING = "LOADING" # Corresponds to the Loading... state.
|
||||||
|
|
||||||
|
|
||||||
|
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: Optional[float] = None
|
||||||
|
stringValue: Optional[str] = None
|
||||||
|
boolValue: Optional[bool] = None
|
||||||
|
formulaValue: Optional[str] = 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 NumberFormatType(str, Enum):
|
||||||
|
NUMBER = "NUMBER"
|
||||||
|
PERCENT = "PERCENT"
|
||||||
|
CURRENCY = "CURRENCY"
|
||||||
|
|
||||||
|
|
||||||
|
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: Optional[CellFormat] = 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: Optional[GridProperties] = 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: Optional[list[GridData]] = 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]
|
||||||
|
|
||||||
|
|
||||||
|
CellValue = int | float | str | bool
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
|
||||||
144
toolkits/google/arcade_google/tools/sheets.py
Normal file
144
toolkits/google/arcade_google/tools/sheets.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from arcade.sdk import ToolContext, tool
|
||||||
|
from arcade.sdk.auth import Google
|
||||||
|
from arcade.sdk.errors import RetryableToolError
|
||||||
|
|
||||||
|
from arcade_google.models import (
|
||||||
|
SheetDataInput,
|
||||||
|
Spreadsheet,
|
||||||
|
SpreadsheetProperties,
|
||||||
|
)
|
||||||
|
from arcade_google.utils import (
|
||||||
|
build_sheets_service,
|
||||||
|
create_sheet,
|
||||||
|
parse_get_spreadsheet_response,
|
||||||
|
parse_write_to_cell_response,
|
||||||
|
validate_write_to_cell_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
requires_auth=Google(
|
||||||
|
scopes=["https://www.googleapis.com/auth/drive.file"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
def create_spreadsheet(
|
||||||
|
context: ToolContext,
|
||||||
|
title: Annotated[str, "The title of the new spreadsheet"] = "Untitled spreadsheet",
|
||||||
|
data: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
"The data to write to the spreadsheet. A JSON string "
|
||||||
|
"(property names enclosed in double quotes) representing a dictionary that "
|
||||||
|
"maps row numbers to dictionaries that map column letters to cell values. "
|
||||||
|
"For example, data[23]['C'] would be the value of the cell in row 23, column C. "
|
||||||
|
"Type hint: dict[int, dict[str, Union[int, float, str, bool]]]",
|
||||||
|
] = None,
|
||||||
|
) -> Annotated[dict, "The created spreadsheet's id and title"]:
|
||||||
|
"""Create a new spreadsheet with the provided title and data in its first sheet
|
||||||
|
|
||||||
|
Returns the newly created spreadsheet's id and title
|
||||||
|
"""
|
||||||
|
service = build_sheets_service(context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
try:
|
||||||
|
sheet_data = SheetDataInput(data=data) # type: ignore[arg-type]
|
||||||
|
except Exception as e:
|
||||||
|
msg = "Invalid JSON or unexpected data format for parameter `data`"
|
||||||
|
raise RetryableToolError(
|
||||||
|
message=msg,
|
||||||
|
additional_prompt_content=f"{msg}: {e}",
|
||||||
|
retry_after_ms=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
spreadsheet = Spreadsheet(
|
||||||
|
properties=SpreadsheetProperties(title=title),
|
||||||
|
sheets=[create_sheet(sheet_data)],
|
||||||
|
)
|
||||||
|
|
||||||
|
body = spreadsheet.model_dump()
|
||||||
|
|
||||||
|
response = (
|
||||||
|
service.spreadsheets()
|
||||||
|
.create(body=body, fields="spreadsheetId,spreadsheetUrl,properties/title")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": response["properties"]["title"],
|
||||||
|
"spreadsheetId": response["spreadsheetId"],
|
||||||
|
"spreadsheetUrl": response["spreadsheetUrl"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
requires_auth=Google(
|
||||||
|
scopes=["https://www.googleapis.com/auth/drive.file"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def get_spreadsheet(
|
||||||
|
context: ToolContext,
|
||||||
|
spreadsheet_id: Annotated[str, "The id of the spreadsheet to get"],
|
||||||
|
) -> Annotated[
|
||||||
|
dict,
|
||||||
|
"The spreadsheet properties and data for all sheets in the spreadsheet",
|
||||||
|
]:
|
||||||
|
"""
|
||||||
|
Get the user entered values and formatted values for all cells in all sheets in the spreadsheet
|
||||||
|
along with the spreadsheet's properties
|
||||||
|
"""
|
||||||
|
service = build_sheets_service(context.get_auth_token_or_empty())
|
||||||
|
response = (
|
||||||
|
service.spreadsheets()
|
||||||
|
.get(
|
||||||
|
spreadsheetId=spreadsheet_id,
|
||||||
|
includeGridData=True,
|
||||||
|
fields="spreadsheetId,spreadsheetUrl,properties/title,sheets/properties,sheets/data/rowData/values/userEnteredValue,sheets/data/rowData/values/formattedValue,sheets/data/rowData/values/effectiveValue",
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return parse_get_spreadsheet_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
requires_auth=Google(
|
||||||
|
scopes=["https://www.googleapis.com/auth/drive.file"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
def write_to_cell(
|
||||||
|
context: ToolContext,
|
||||||
|
spreadsheet_id: Annotated[str, "The id of the spreadsheet to write to"],
|
||||||
|
column: Annotated[str, "The column string to write to. For example, 'A', 'F', or 'AZ'"],
|
||||||
|
row: Annotated[int, "The row number to write to"],
|
||||||
|
value: Annotated[str, "The value to write to the cell"],
|
||||||
|
sheet_name: Annotated[
|
||||||
|
str, "The name of the sheet to write to. Defaults to 'Sheet1'"
|
||||||
|
] = "Sheet1",
|
||||||
|
) -> Annotated[dict, "The status of the operation"]:
|
||||||
|
"""
|
||||||
|
Write a value to a single cell in a spreadsheet.
|
||||||
|
"""
|
||||||
|
service = build_sheets_service(context.get_auth_token_or_empty())
|
||||||
|
validate_write_to_cell_params(service, spreadsheet_id, sheet_name, column, row)
|
||||||
|
|
||||||
|
range_ = f"'{sheet_name}'!{column.upper()}{row}"
|
||||||
|
body = {
|
||||||
|
"range": range_,
|
||||||
|
"majorDimension": "ROWS",
|
||||||
|
"values": [[value]],
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet_properties = (
|
||||||
|
service.spreadsheets()
|
||||||
|
.values()
|
||||||
|
.update(
|
||||||
|
spreadsheetId=spreadsheet_id,
|
||||||
|
range=range_,
|
||||||
|
valueInputOption="USER_ENTERED",
|
||||||
|
includeValuesInResponse=True,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
|
return parse_write_to_cell_response(sheet_properties)
|
||||||
|
|
@ -9,13 +9,37 @@ from typing import Any, Optional, Union, cast
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from arcade.sdk import ToolContext
|
from arcade.sdk import ToolContext
|
||||||
|
from arcade.sdk.errors import RetryableToolError, ToolExecutionError
|
||||||
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 arcade_google.constants import DEFAULT_SEARCH_CONTACTS_LIMIT
|
from arcade_google.constants import (
|
||||||
|
DEFAULT_SEARCH_CONTACTS_LIMIT,
|
||||||
|
DEFAULT_SHEET_COLUMN_COUNT,
|
||||||
|
DEFAULT_SHEET_ROW_COUNT,
|
||||||
|
)
|
||||||
from arcade_google.exceptions import GmailToolError, GoogleServiceError
|
from arcade_google.exceptions import GmailToolError, GoogleServiceError
|
||||||
from arcade_google.models import Corpora, Day, GmailAction, GmailReplyToWhom, OrderBy, TimeSlot
|
from arcade_google.models import (
|
||||||
|
CellData,
|
||||||
|
CellExtendedValue,
|
||||||
|
CellFormat,
|
||||||
|
CellValue,
|
||||||
|
Corpora,
|
||||||
|
Day,
|
||||||
|
GmailAction,
|
||||||
|
GmailReplyToWhom,
|
||||||
|
GridData,
|
||||||
|
GridProperties,
|
||||||
|
NumberFormat,
|
||||||
|
NumberFormatType,
|
||||||
|
OrderBy,
|
||||||
|
RowData,
|
||||||
|
Sheet,
|
||||||
|
SheetDataInput,
|
||||||
|
SheetProperties,
|
||||||
|
TimeSlot,
|
||||||
|
)
|
||||||
|
|
||||||
## Set up basic configuration for logging to the console with DEBUG level and a specific format.
|
## Set up basic configuration for logging to the console with DEBUG level and a specific format.
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -805,3 +829,524 @@ def search_contacts(service: Any, query: str, limit: Optional[int]) -> list[dict
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast(list[dict[str, Any]], response.get("results", []))
|
return cast(list[dict[str, Any]], response.get("results", []))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Sheets utils
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_sheets_service(auth_token: Optional[str]) -> 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],
|
||||||
|
}
|
||||||
|
|
|
||||||
169
toolkits/google/evals/eval_google_sheets.py
Normal file
169
toolkits/google/evals/eval_google_sheets.py
Normal file
File diff suppressed because one or more lines are too long
84
toolkits/google/tests/test_sheets_models.py
Normal file
84
toolkits/google/tests/test_sheets_models.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
from arcade_google.models import SheetDataInput
|
||||||
|
|
||||||
|
|
||||||
|
def test_sheet_input_data_init():
|
||||||
|
data = '{"1":{"A":"name","B":"age","C":"email","D":"score","E":"gender","F":"city","G":"country","H":"registration_date"},"34":{"A":"Isla Green","B":24,"C":"islag@example.com","D":79,"E":"Female","F":"Chicago","G":"USA","H":"2024-01-10"},"38":{"A":"Mia Black","B":27,"C":"miab@example.com","D":80,"E":"Female","F":"Denver","G":"USA","H":"2024-01-30"},"39":{"A":"Nate Green","B":30,"C":"nateg@example.com","D":88,"E":"Male","F":"Orlando","G":"USA","H":"2024-02-01"},"43":{"A":100,"B":300,"C":234,"D":399,"E":5039,"F":2345,"G":23526,"H":123,"I":54,"J":234,"K":54,"L":23,"M":12,"N":57,"O":1324},"47":{"A":456,"B":234,"C":234,"D":399,"E":234,"F":1234,"G":23526,"H":123,"I":54,"J":234,"K":4567,"L":23,"M":12,"N":234,"O":1324}}'
|
||||||
|
expected_data = {
|
||||||
|
1: {
|
||||||
|
"A": "name",
|
||||||
|
"B": "age",
|
||||||
|
"C": "email",
|
||||||
|
"D": "score",
|
||||||
|
"E": "gender",
|
||||||
|
"F": "city",
|
||||||
|
"G": "country",
|
||||||
|
"H": "registration_date",
|
||||||
|
},
|
||||||
|
34: {
|
||||||
|
"A": "Isla Green",
|
||||||
|
"B": 24,
|
||||||
|
"C": "islag@example.com",
|
||||||
|
"D": 79,
|
||||||
|
"E": "Female",
|
||||||
|
"F": "Chicago",
|
||||||
|
"G": "USA",
|
||||||
|
"H": "2024-01-10",
|
||||||
|
},
|
||||||
|
38: {
|
||||||
|
"A": "Mia Black",
|
||||||
|
"B": 27,
|
||||||
|
"C": "miab@example.com",
|
||||||
|
"D": 80,
|
||||||
|
"E": "Female",
|
||||||
|
"F": "Denver",
|
||||||
|
"G": "USA",
|
||||||
|
"H": "2024-01-30",
|
||||||
|
},
|
||||||
|
39: {
|
||||||
|
"A": "Nate Green",
|
||||||
|
"B": 30,
|
||||||
|
"C": "nateg@example.com",
|
||||||
|
"D": 88,
|
||||||
|
"E": "Male",
|
||||||
|
"F": "Orlando",
|
||||||
|
"G": "USA",
|
||||||
|
"H": "2024-02-01",
|
||||||
|
},
|
||||||
|
43: {
|
||||||
|
"A": 100,
|
||||||
|
"B": 300,
|
||||||
|
"C": 234,
|
||||||
|
"D": 399,
|
||||||
|
"E": 5039,
|
||||||
|
"F": 2345,
|
||||||
|
"G": 23526,
|
||||||
|
"H": 123,
|
||||||
|
"I": 54,
|
||||||
|
"J": 234,
|
||||||
|
"K": 54,
|
||||||
|
"L": 23,
|
||||||
|
"M": 12,
|
||||||
|
"N": 57,
|
||||||
|
"O": 1324,
|
||||||
|
},
|
||||||
|
47: {
|
||||||
|
"A": 456,
|
||||||
|
"B": 234,
|
||||||
|
"C": 234,
|
||||||
|
"D": 399,
|
||||||
|
"E": 234,
|
||||||
|
"F": 1234,
|
||||||
|
"G": 23526,
|
||||||
|
"H": 123,
|
||||||
|
"I": 54,
|
||||||
|
"J": 234,
|
||||||
|
"K": 4567,
|
||||||
|
"L": 23,
|
||||||
|
"M": 12,
|
||||||
|
"N": 234,
|
||||||
|
"O": 1324,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet_input_data = SheetDataInput(data=data)
|
||||||
|
assert sheet_input_data.data == expected_data
|
||||||
542
toolkits/google/tests/test_sheets_utils.py
Normal file
542
toolkits/google/tests/test_sheets_utils.py
Normal file
|
|
@ -0,0 +1,542 @@
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade.sdk.errors import RetryableToolError, ToolExecutionError
|
||||||
|
|
||||||
|
from arcade_google.models import (
|
||||||
|
CellData,
|
||||||
|
CellExtendedValue,
|
||||||
|
NumberFormatType,
|
||||||
|
RowData,
|
||||||
|
SheetDataInput,
|
||||||
|
)
|
||||||
|
from arcade_google.utils import (
|
||||||
|
col_to_index,
|
||||||
|
compute_sheet_data_dimensions,
|
||||||
|
convert_api_grid_data_to_dict,
|
||||||
|
create_cell_data,
|
||||||
|
create_row_data,
|
||||||
|
create_sheet_data,
|
||||||
|
create_sheet_properties,
|
||||||
|
extract_user_entered_cell_value,
|
||||||
|
group_contiguous_rows,
|
||||||
|
index_to_col,
|
||||||
|
is_col_greater,
|
||||||
|
process_row,
|
||||||
|
validate_write_to_cell_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sheet_data_input_fixture():
|
||||||
|
data = {
|
||||||
|
1: {
|
||||||
|
"A": "name",
|
||||||
|
"B": "age",
|
||||||
|
"C": "email",
|
||||||
|
"D": "score",
|
||||||
|
"E": "gender",
|
||||||
|
"F": "city",
|
||||||
|
"G": "country",
|
||||||
|
"H": "registration_date",
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"A": "John Doe",
|
||||||
|
"B": 28,
|
||||||
|
"C": "johndoe@example.com",
|
||||||
|
"D": 85.4,
|
||||||
|
"E": "Male",
|
||||||
|
"F": "New York",
|
||||||
|
"G": "USA",
|
||||||
|
"H": "2023-01-15",
|
||||||
|
},
|
||||||
|
10: {
|
||||||
|
"A": "Nate Green",
|
||||||
|
"B": 30,
|
||||||
|
"C": "nateg@example.com",
|
||||||
|
"D": 88,
|
||||||
|
"E": "Male",
|
||||||
|
"F": "Orlando",
|
||||||
|
"G": "USA",
|
||||||
|
"H": "2024-02-01",
|
||||||
|
},
|
||||||
|
43: {
|
||||||
|
"A": 100,
|
||||||
|
"B": 300,
|
||||||
|
"H": 123,
|
||||||
|
"I": "=SUM(SEQUENCE(10))",
|
||||||
|
},
|
||||||
|
44: {
|
||||||
|
"A": 456,
|
||||||
|
"B": 234,
|
||||||
|
"H": 123,
|
||||||
|
"I": "=SUM(SEQUENCE(10))",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return SheetDataInput(data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"col, expected_index",
|
||||||
|
[
|
||||||
|
("A", 0),
|
||||||
|
("B", 1),
|
||||||
|
("Z", 25),
|
||||||
|
("AA", 26 + 0),
|
||||||
|
("AZ", (1 * 26) + 25),
|
||||||
|
("BA", (2 * 26) + 0),
|
||||||
|
("ZZ", (26 * 26) + 25),
|
||||||
|
("AAA", (1 * 26 * 26) + (1 * 26) + 0),
|
||||||
|
("AAB", (1 * 26 * 26) + (1 * 26) + 1),
|
||||||
|
("QED", (17 * 26 * 26) + (5 * 26) + 3),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_col_to_index(col, expected_index):
|
||||||
|
assert col_to_index(col) == expected_index
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"index, expected_col",
|
||||||
|
[
|
||||||
|
(0, "A"),
|
||||||
|
(1, "B"),
|
||||||
|
(25, "Z"),
|
||||||
|
(26 + 0, "AA"),
|
||||||
|
((1 * 26) + 25, "AZ"),
|
||||||
|
((2 * 26) + 0, "BA"),
|
||||||
|
((26 * 26) + 25, "ZZ"),
|
||||||
|
((1 * 26 * 26) + (1 * 26) + 0, "AAA"),
|
||||||
|
((1 * 26 * 26) + (1 * 26) + 1, "AAB"),
|
||||||
|
((17 * 26 * 26) + (5 * 26) + 3, "QED"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_index_to_col(index, expected_col):
|
||||||
|
assert index_to_col(index) == expected_col
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"col1, col2, expected_result",
|
||||||
|
[
|
||||||
|
("A", "B", False),
|
||||||
|
("B", "A", True),
|
||||||
|
("AA", "AB", False),
|
||||||
|
("AB", "AA", True),
|
||||||
|
("A", "AA", False),
|
||||||
|
("AA", "A", True),
|
||||||
|
("Z", "AA", False),
|
||||||
|
("AA", "Z", True),
|
||||||
|
("AAA", "AAB", False),
|
||||||
|
("AAB", "AAA", True),
|
||||||
|
("QED", "QEE", False),
|
||||||
|
("QEE", "QED", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_col_greater(col1, col2, expected_result):
|
||||||
|
assert is_col_greater(col1, col2) == expected_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_sheet_data_dimensions(sheet_data_input_fixture):
|
||||||
|
(min_row, max_row), (min_col_index, max_col_index) = compute_sheet_data_dimensions(
|
||||||
|
sheet_data_input_fixture
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_min_row = 1
|
||||||
|
expected_max_row = 44
|
||||||
|
expected_min_col_index = 0 # Column "A"
|
||||||
|
expected_max_col_index = 8 # Column "I"
|
||||||
|
|
||||||
|
assert min_row == expected_min_row
|
||||||
|
assert max_row == expected_max_row
|
||||||
|
assert min_col_index == expected_min_col_index
|
||||||
|
assert max_col_index == expected_max_col_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_sheet_properties():
|
||||||
|
sheet_properties = create_sheet_properties(
|
||||||
|
sheet_id=1,
|
||||||
|
title="Sheet1",
|
||||||
|
row_count=10000,
|
||||||
|
column_count=260,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sheet_properties.sheetId == 1
|
||||||
|
assert sheet_properties.title == "Sheet1"
|
||||||
|
assert sheet_properties.gridProperties.rowCount == 10000
|
||||||
|
assert sheet_properties.gridProperties.columnCount == 260
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"row_numbers, expected_groups",
|
||||||
|
[
|
||||||
|
([], []),
|
||||||
|
([5, 6, 7], [[5, 6, 7]]),
|
||||||
|
(
|
||||||
|
[1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 18, 19, 20],
|
||||||
|
[[1, 2, 3], [5, 6, 7, 8, 9, 10, 11], [18, 19, 20]],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_group_contiguous_rows(row_numbers, expected_groups):
|
||||||
|
grouped_rows = group_contiguous_rows(row_numbers)
|
||||||
|
assert grouped_rows == expected_groups
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_value, expected_key, expected_value, expected_type, expected_pattern",
|
||||||
|
[
|
||||||
|
(1234, "numberValue", 1234, NumberFormatType.NUMBER, "#,##0"),
|
||||||
|
(1.234, "numberValue", 1.234, NumberFormatType.NUMBER, "#,##0.00"),
|
||||||
|
("$100", "numberValue", 100, NumberFormatType.CURRENCY, "$#,##0"),
|
||||||
|
("$100.50", "numberValue", 100.50, NumberFormatType.CURRENCY, "$#,##0.00"),
|
||||||
|
("75%", "numberValue", 75.00, NumberFormatType.PERCENT, "0.00%"),
|
||||||
|
("75.34%", "numberValue", 75.34, NumberFormatType.PERCENT, "0.00%"),
|
||||||
|
("$1abc", "stringValue", "$1abc", None, None),
|
||||||
|
("abc7%", "stringValue", "abc7%", None, None),
|
||||||
|
("=SUM(A1:B1)", "formulaValue", "=SUM(A1:B1)", None, None),
|
||||||
|
(True, "boolValue", True, None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_create_cell_data(
|
||||||
|
input_value, expected_key, expected_value, expected_type, expected_pattern
|
||||||
|
):
|
||||||
|
cell_data = create_cell_data(input_value)
|
||||||
|
expected_cell_value = CellExtendedValue(**{expected_key: expected_value})
|
||||||
|
assert cell_data.userEnteredValue == expected_cell_value
|
||||||
|
if expected_type is None:
|
||||||
|
assert cell_data.userEnteredFormat is None
|
||||||
|
else:
|
||||||
|
assert cell_data.userEnteredFormat is not None
|
||||||
|
assert cell_data.userEnteredFormat.numberFormat.type == expected_type
|
||||||
|
assert cell_data.userEnteredFormat.numberFormat.pattern == expected_pattern
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_row_data():
|
||||||
|
row_data = {
|
||||||
|
"A": 1, # Column index 0
|
||||||
|
"B": 2.5, # Column index 1
|
||||||
|
"AA": "test", # Column index 26
|
||||||
|
"BA": True, # Column index 52
|
||||||
|
"BB": "=SUM(A1:B1)", # Column index 53
|
||||||
|
}
|
||||||
|
min_col_index = 0 # Column "A"
|
||||||
|
max_col_index = 53 # Column "BB"
|
||||||
|
|
||||||
|
expected_row_data = RowData(
|
||||||
|
values=[
|
||||||
|
CellData(userEnteredValue=CellExtendedValue(stringValue=""))
|
||||||
|
for _ in range(max_col_index + 1)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
expected_row_data.values[0].userEnteredValue = CellExtendedValue(numberValue=1)
|
||||||
|
expected_row_data.values[1].userEnteredValue = CellExtendedValue(numberValue=2.5)
|
||||||
|
expected_row_data.values[26].userEnteredValue = CellExtendedValue(stringValue="test")
|
||||||
|
expected_row_data.values[52].userEnteredValue = CellExtendedValue(boolValue=True)
|
||||||
|
expected_row_data.values[53].userEnteredValue = CellExtendedValue(formulaValue="=SUM(A1:B1)")
|
||||||
|
|
||||||
|
row_data = create_row_data(row_data, min_col_index, max_col_index)
|
||||||
|
|
||||||
|
assert len(row_data.values) == len(expected_row_data.values)
|
||||||
|
for cell, expected in zip(row_data.values, expected_row_data.values):
|
||||||
|
assert cell.userEnteredValue == expected.userEnteredValue
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_sheet_data():
|
||||||
|
from arcade_google.models import CellData, CellExtendedValue, SheetDataInput
|
||||||
|
from arcade_google.utils import create_cell_data
|
||||||
|
|
||||||
|
test_data = {
|
||||||
|
2: {"B": "row2B", "C": 200},
|
||||||
|
3: {"B": "row3B"},
|
||||||
|
5: {"A": "=SUM(A1:A1)", "C": "row5C"},
|
||||||
|
}
|
||||||
|
sheet_data_input = SheetDataInput(data=test_data)
|
||||||
|
min_col_index = 0 # Column "A"
|
||||||
|
max_col_index = 2 # Column "C"
|
||||||
|
|
||||||
|
grid_data_list = create_sheet_data(sheet_data_input, min_col_index, max_col_index)
|
||||||
|
|
||||||
|
assert len(grid_data_list) == 2, "Should have two groups of contiguous rows"
|
||||||
|
|
||||||
|
group1 = grid_data_list[0]
|
||||||
|
assert group1.startRow == 1
|
||||||
|
assert group1.startColumn == min_col_index
|
||||||
|
assert len(group1.rowData) == 2
|
||||||
|
|
||||||
|
row2_cells = group1.rowData[0].values
|
||||||
|
expected_row2 = [
|
||||||
|
CellData(userEnteredValue=CellExtendedValue(stringValue="")),
|
||||||
|
create_cell_data("row2B"),
|
||||||
|
create_cell_data(200),
|
||||||
|
]
|
||||||
|
for cell, expected in zip(row2_cells, expected_row2):
|
||||||
|
assert cell.userEnteredValue == expected.userEnteredValue
|
||||||
|
|
||||||
|
row3_cells = group1.rowData[1].values
|
||||||
|
expected_row3 = [
|
||||||
|
CellData(userEnteredValue=CellExtendedValue(stringValue="")),
|
||||||
|
create_cell_data("row3B"),
|
||||||
|
CellData(userEnteredValue=CellExtendedValue(stringValue="")),
|
||||||
|
]
|
||||||
|
for cell, expected in zip(row3_cells, expected_row3):
|
||||||
|
assert cell.userEnteredValue == expected.userEnteredValue
|
||||||
|
|
||||||
|
group2 = grid_data_list[1]
|
||||||
|
assert group2.startRow == 4
|
||||||
|
assert group2.startColumn == min_col_index
|
||||||
|
assert len(group2.rowData) == 1
|
||||||
|
|
||||||
|
row5_cells = group2.rowData[0].values
|
||||||
|
expected_row5 = [
|
||||||
|
create_cell_data("=SUM(A1:A1)"),
|
||||||
|
CellData(userEnteredValue=CellExtendedValue(stringValue="")),
|
||||||
|
create_cell_data("row5C"),
|
||||||
|
]
|
||||||
|
for cell, expected in zip(row5_cells, expected_row5):
|
||||||
|
assert cell.userEnteredValue == expected.userEnteredValue
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cell, expected",
|
||||||
|
[
|
||||||
|
({}, ""),
|
||||||
|
({"userEnteredValue": {}}, ""),
|
||||||
|
({"userEnteredValue": {"stringValue": "hello"}}, "hello"),
|
||||||
|
({"userEnteredValue": {"numberValue": 123}}, 123),
|
||||||
|
({"userEnteredValue": {"boolValue": True}}, True),
|
||||||
|
({"userEnteredValue": {"formulaValue": "=SUM(A1:A2)"}}, "=SUM(A1:A2)"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_extract_user_entered_cell_value(cell, expected):
|
||||||
|
result = extract_user_entered_cell_value(cell)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_row_empty():
|
||||||
|
row = {}
|
||||||
|
assert process_row(row, 0) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_row_non_empty():
|
||||||
|
row = {
|
||||||
|
"values": [
|
||||||
|
{"userEnteredValue": {"stringValue": "cell1"}, "formattedValue": "cell1"},
|
||||||
|
{"userEnteredValue": {}}, # should be ignored
|
||||||
|
{"userEnteredValue": {"formulaValue": "=C1+D4"}, "formattedValue": 42},
|
||||||
|
{"userEnteredValue": {"stringValue": ""}, "formattedValue": ""}, # should be ignored
|
||||||
|
{"userEnteredValue": {"boolValue": False}, "formattedValue": False},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expected = {
|
||||||
|
"A": {"userEnteredValue": "cell1", "formattedValue": "cell1"},
|
||||||
|
"C": {"userEnteredValue": "=C1+D4", "formattedValue": 42},
|
||||||
|
"E": {"userEnteredValue": False, "formattedValue": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert process_row(row, 0) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_row_with_start_index():
|
||||||
|
row = {
|
||||||
|
"values": [
|
||||||
|
{"userEnteredValue": {"stringValue": "x"}, "formattedValue": "x"},
|
||||||
|
{"userEnteredValue": {"formulaValue": "=C1+D4"}, "formattedValue": "$10.00"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expected = {
|
||||||
|
"C": {"userEnteredValue": "x", "formattedValue": "x"},
|
||||||
|
"D": {"userEnteredValue": "=C1+D4", "formattedValue": "$10.00"},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert process_row(row, 2) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_api_grid_data_to_dict_single_grid():
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"startRow": 0,
|
||||||
|
"startColumn": 0,
|
||||||
|
"rowData": [
|
||||||
|
{
|
||||||
|
"values": [
|
||||||
|
{"userEnteredValue": {"stringValue": "A1"}, "formattedValue": "A1"},
|
||||||
|
{"userEnteredValue": {"numberValue": 1}, "formattedValue": 1},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": [
|
||||||
|
{"userEnteredValue": {"stringValue": "A2"}, "formattedValue": "A2"},
|
||||||
|
{"userEnteredValue": {"numberValue": 2}, "formattedValue": 2},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": [
|
||||||
|
{"userEnteredValue": {}},
|
||||||
|
{
|
||||||
|
"userEnteredValue": {"stringValue": "ignored"},
|
||||||
|
"formattedValue": "ignored",
|
||||||
|
},
|
||||||
|
{"userEnteredValue": {"numberValue": 3}, "formattedValue": 3},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = convert_api_grid_data_to_dict(data)
|
||||||
|
expected = {
|
||||||
|
1: {
|
||||||
|
"A": {"userEnteredValue": "A1", "formattedValue": "A1"},
|
||||||
|
"B": {"userEnteredValue": 1, "formattedValue": 1},
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"A": {"userEnteredValue": "A2", "formattedValue": "A2"},
|
||||||
|
"B": {"userEnteredValue": 2, "formattedValue": 2},
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
"B": {"userEnteredValue": "ignored", "formattedValue": "ignored"},
|
||||||
|
"C": {"userEnteredValue": 3, "formattedValue": 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_api_grid_data_to_dict_multiple_grids():
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"startRow": 5,
|
||||||
|
"startColumn": 1,
|
||||||
|
"rowData": [
|
||||||
|
{
|
||||||
|
"values": [
|
||||||
|
{"userEnteredValue": {"numberValue": 100}, "formattedValue": 100},
|
||||||
|
{"userEnteredValue": {"stringValue": "=SUM(A1:A2)"}, "formattedValue": 23},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"startRow": 0,
|
||||||
|
"startColumn": 0,
|
||||||
|
"rowData": [
|
||||||
|
{
|
||||||
|
"values": [
|
||||||
|
{"userEnteredValue": {"stringValue": "First"}, "formattedValue": "First"},
|
||||||
|
{"userEnteredValue": {"numberValue": 10}, "formattedValue": 10},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
result = convert_api_grid_data_to_dict(data)
|
||||||
|
expected = {
|
||||||
|
1: {
|
||||||
|
"A": {"userEnteredValue": "First", "formattedValue": "First"},
|
||||||
|
"B": {"userEnteredValue": 10, "formattedValue": 10},
|
||||||
|
},
|
||||||
|
6: {
|
||||||
|
"B": {"userEnteredValue": 100, "formattedValue": 100},
|
||||||
|
"C": {"userEnteredValue": "=SUM(A1:A2)", "formattedValue": 23},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_api_grid_data_to_dict_empty_rows():
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"startRow": 10,
|
||||||
|
"startColumn": 0,
|
||||||
|
"rowData": [
|
||||||
|
{"values": [{"userEnteredValue": {}, "formattedValue": ""}]},
|
||||||
|
{"values": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = convert_api_grid_data_to_dict(data)
|
||||||
|
expected = {}
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
FAKE_SHEET_RESPONSE = {
|
||||||
|
"sheets": [
|
||||||
|
{"properties": {"title": "Sheet1", "gridProperties": {"rowCount": 10, "columnCount": 6}}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("arcade_google.utils.build_sheets_service")
|
||||||
|
def test_validate_write_to_cell_params_valid(mock_build):
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
service = mock_build("dummy_token")
|
||||||
|
|
||||||
|
validate_write_to_cell_params(
|
||||||
|
service=service,
|
||||||
|
spreadsheet_id="dummy_id",
|
||||||
|
sheet_name="Sheet1",
|
||||||
|
column="B",
|
||||||
|
row=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("arcade_google.utils.build_sheets_service")
|
||||||
|
def test_validate_write_to_cell_params_invalid_sheet_name(mock_build):
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
service = mock_build("dummy_token")
|
||||||
|
|
||||||
|
with pytest.raises(RetryableToolError) as excinfo:
|
||||||
|
validate_write_to_cell_params(
|
||||||
|
service=service,
|
||||||
|
spreadsheet_id="dummy_id",
|
||||||
|
sheet_name="NonExistentSheet",
|
||||||
|
column="A",
|
||||||
|
row=5,
|
||||||
|
)
|
||||||
|
assert "Sheet name NonExistentSheet not found" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("arcade_google.utils.build_sheets_service")
|
||||||
|
def test_validate_write_to_cell_params_row_out_of_bounds(mock_build):
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
service = mock_build("dummy_token")
|
||||||
|
|
||||||
|
out_of_bounds_row = 15
|
||||||
|
with pytest.raises(ToolExecutionError) as excinfo:
|
||||||
|
validate_write_to_cell_params(
|
||||||
|
service=service,
|
||||||
|
spreadsheet_id="dummy_id",
|
||||||
|
sheet_name="Sheet1",
|
||||||
|
column="A",
|
||||||
|
row=out_of_bounds_row,
|
||||||
|
)
|
||||||
|
assert f"Row {out_of_bounds_row} is out of bounds" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("arcade_google.utils.build_sheets_service")
|
||||||
|
def test_validate_write_to_cell_params_column_out_of_bounds(mock_build):
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
service = mock_build("dummy_token")
|
||||||
|
|
||||||
|
out_of_bounds_column = "Z"
|
||||||
|
with pytest.raises(ToolExecutionError) as excinfo:
|
||||||
|
validate_write_to_cell_params(
|
||||||
|
service=service,
|
||||||
|
spreadsheet_id="dummy_id",
|
||||||
|
sheet_name="Sheet1",
|
||||||
|
column=out_of_bounds_column,
|
||||||
|
row=5,
|
||||||
|
)
|
||||||
|
assert f"Column {out_of_bounds_column} is out of bounds" in str(excinfo.value)
|
||||||
Loading…
Reference in a new issue