arcade-google-sheets 2.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ from arcade_google_sheets.tools import (
2
+ create_spreadsheet,
3
+ get_spreadsheet,
4
+ write_to_cell,
5
+ )
6
+
7
+ __all__ = ["create_spreadsheet", "get_spreadsheet", "write_to_cell"]
@@ -0,0 +1,2 @@
1
+ DEFAULT_SHEET_ROW_COUNT = 1000
2
+ DEFAULT_SHEET_COLUMN_COUNT = 26
@@ -0,0 +1,24 @@
1
+ import functools
2
+ from collections.abc import Callable
3
+ from typing import Any
4
+
5
+ from arcade_tdk import ToolContext
6
+ from googleapiclient.errors import HttpError
7
+
8
+ from arcade_google_sheets.file_picker import generate_google_file_picker_url
9
+
10
+
11
+ def with_filepicker_fallback(func: Callable[..., Any]) -> Callable[..., Any]:
12
+ """ """
13
+
14
+ @functools.wraps(func)
15
+ async def async_wrapper(context: ToolContext, *args: Any, **kwargs: Any) -> Any:
16
+ try:
17
+ return await func(context, *args, **kwargs)
18
+ except HttpError as e:
19
+ if e.status_code in [403, 404]:
20
+ file_picker_response = generate_google_file_picker_url(context)
21
+ return file_picker_response
22
+ raise
23
+
24
+ return async_wrapper
@@ -0,0 +1,25 @@
1
+ from enum import Enum
2
+
3
+
4
+ class CellErrorType(str, Enum):
5
+ """The type of error in a cell
6
+
7
+ Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorType
8
+ """
9
+
10
+ ERROR_TYPE_UNSPECIFIED = "ERROR_TYPE_UNSPECIFIED" # The default error type, do not use this.
11
+ ERROR = "ERROR" # Corresponds to the #ERROR! error.
12
+ NULL_VALUE = "NULL_VALUE" # Corresponds to the #NULL! error.
13
+ DIVIDE_BY_ZERO = "DIVIDE_BY_ZERO" # Corresponds to the #DIV/0 error.
14
+ VALUE = "VALUE" # Corresponds to the #VALUE! error.
15
+ REF = "REF" # Corresponds to the #REF! error.
16
+ NAME = "NAME" # Corresponds to the #NAME? error.
17
+ NUM = "NUM" # Corresponds to the #NUM! error.
18
+ N_A = "N_A" # Corresponds to the #N/A error.
19
+ LOADING = "LOADING" # Corresponds to the Loading... state.
20
+
21
+
22
+ class NumberFormatType(str, Enum):
23
+ NUMBER = "NUMBER"
24
+ PERCENT = "PERCENT"
25
+ CURRENCY = "CURRENCY"
@@ -0,0 +1,49 @@
1
+ import base64
2
+ import json
3
+
4
+ from arcade_tdk import ToolContext, ToolMetadataKey
5
+ from arcade_tdk.errors import ToolExecutionError
6
+
7
+
8
+ def generate_google_file_picker_url(context: ToolContext) -> dict:
9
+ """Generate a Google File Picker URL for user-driven file selection and authorization.
10
+
11
+ Generates a URL that directs the end-user to a Google File Picker interface where
12
+ where they can select or upload Google Drive files. Users can grant permission to access their
13
+ Drive files, providing a secure and authorized way to interact with their files.
14
+
15
+ This is particularly useful when prior tools (e.g., those accessing or modifying
16
+ Google Docs, Google Sheets, etc.) encountered failures due to file non-existence
17
+ (Requested entity was not found) or permission errors. Once the user completes the file
18
+ picker flow, the prior tool can be retried.
19
+
20
+ Returns:
21
+ A dictionary containing the URL and instructions for the llm to instruct the user.
22
+ """
23
+ client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID)
24
+ client_id_parts = client_id.split("-")
25
+ if not client_id_parts:
26
+ raise ToolExecutionError(
27
+ message="Invalid Google Client ID",
28
+ developer_message=f"Google Client ID '{client_id}' is not valid",
29
+ )
30
+ app_id = client_id_parts[0]
31
+ cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/")
32
+
33
+ config = {
34
+ "auth": {
35
+ "client_id": client_id,
36
+ "app_id": app_id,
37
+ },
38
+ }
39
+ config_json = json.dumps(config)
40
+ config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8")
41
+ url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}"
42
+
43
+ return {
44
+ "url": url,
45
+ "llm_instructions": (
46
+ "Instruct the user to click the following link to open the Google Drive File Picker. "
47
+ f"This will allow them to select files and grant access permissions: {url}"
48
+ ),
49
+ }
@@ -0,0 +1,241 @@
1
+ import json
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, field_validator, model_validator
5
+
6
+ from arcade_google_sheets.enums import CellErrorType, NumberFormatType
7
+ from arcade_google_sheets.types import CellValue
8
+
9
+
10
+ class CellErrorValue(BaseModel):
11
+ """An error in a cell
12
+
13
+ Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorValue
14
+ """
15
+
16
+ type: CellErrorType
17
+ message: str
18
+
19
+
20
+ class CellExtendedValue(BaseModel):
21
+ """The kinds of value that a cell in a spreadsheet can have
22
+
23
+ Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ExtendedValue
24
+ """
25
+
26
+ numberValue: float | None = None
27
+ stringValue: str | None = None
28
+ boolValue: bool | None = None
29
+ formulaValue: str | None = None
30
+ errorValue: Optional["CellErrorValue"] = None
31
+
32
+ @model_validator(mode="after")
33
+ def check_exactly_one_value(cls, instance): # type: ignore[no-untyped-def]
34
+ provided = [v for v in instance.__dict__.values() if v is not None]
35
+ if len(provided) != 1:
36
+ raise ValueError(
37
+ "Exactly one of numberValue, stringValue, boolValue, "
38
+ "formulaValue, or errorValue must be set."
39
+ )
40
+ return instance
41
+
42
+
43
+ class NumberFormat(BaseModel):
44
+ """The format of a number
45
+
46
+ Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#NumberFormat
47
+ """
48
+
49
+ pattern: str
50
+ type: NumberFormatType
51
+
52
+
53
+ class CellFormat(BaseModel):
54
+ """The format of a cell
55
+
56
+ Partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat
57
+ """
58
+
59
+ numberFormat: NumberFormat
60
+
61
+
62
+ class CellData(BaseModel):
63
+ """Data about a specific cell
64
+
65
+ A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellData
66
+ """
67
+
68
+ userEnteredValue: CellExtendedValue
69
+ userEnteredFormat: CellFormat | None = None
70
+
71
+
72
+ class RowData(BaseModel):
73
+ """Data about each cellin a row
74
+
75
+ A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#RowData
76
+ """
77
+
78
+ values: list[CellData]
79
+
80
+
81
+ class GridData(BaseModel):
82
+ """Data in the grid
83
+
84
+ A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridData
85
+ """
86
+
87
+ startRow: int
88
+ startColumn: int
89
+ rowData: list[RowData]
90
+
91
+
92
+ class GridProperties(BaseModel):
93
+ """Properties of a grid
94
+
95
+ A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridProperties
96
+ """
97
+
98
+ rowCount: int
99
+ columnCount: int
100
+
101
+
102
+ class SheetProperties(BaseModel):
103
+ """Properties of a Sheet
104
+
105
+ A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#SheetProperties
106
+ """
107
+
108
+ sheetId: int
109
+ title: str
110
+ gridProperties: GridProperties | None = None
111
+
112
+
113
+ class Sheet(BaseModel):
114
+ """A Sheet in a spreadsheet
115
+
116
+ A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#Sheet
117
+ """
118
+
119
+ properties: SheetProperties
120
+ data: list[GridData] | None = None
121
+
122
+
123
+ class SpreadsheetProperties(BaseModel):
124
+ """Properties of a spreadsheet
125
+
126
+ A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties
127
+ """
128
+
129
+ title: str
130
+
131
+
132
+ class Spreadsheet(BaseModel):
133
+ """A spreadsheet
134
+
135
+ A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets
136
+ """
137
+
138
+ properties: SpreadsheetProperties
139
+ sheets: list[Sheet]
140
+
141
+
142
+ class SheetDataInput(BaseModel):
143
+ """
144
+ SheetDataInput models the cell data of a spreadsheet in a custom format.
145
+
146
+ It is a dictionary mapping row numbers (as ints) to dictionaries that map
147
+ column letters (as uppercase strings) to cell values (int, float, str, or bool).
148
+
149
+ This model enforces that:
150
+ - The outer keys are convertible to int.
151
+ - The inner keys are alphabetic strings (normalized to uppercase).
152
+ - All cell values are only of type int, float, str, or bool.
153
+
154
+ The model automatically serializes (via `json_data()`)
155
+ and validates the inner types.
156
+ """
157
+
158
+ data: dict[int, dict[str, CellValue]]
159
+
160
+ @classmethod
161
+ def _parse_json_if_string(cls, value): # type: ignore[no-untyped-def]
162
+ """Parses the value if it is a JSON string, otherwise returns it.
163
+
164
+ Helper method for when validating the `data` field.
165
+ """
166
+ if isinstance(value, str):
167
+ try:
168
+ return json.loads(value)
169
+ except json.JSONDecodeError as e:
170
+ raise TypeError(f"Invalid JSON: {e}")
171
+ return value
172
+
173
+ @classmethod
174
+ def _validate_row_key(cls, row_key) -> int: # type: ignore[no-untyped-def]
175
+ """Converts the row key to an integer, raising an error if conversion fails.
176
+
177
+ Helper method for when validating the `data` field.
178
+ """
179
+ try:
180
+ return int(row_key)
181
+ except (ValueError, TypeError):
182
+ raise TypeError(f"Row key '{row_key}' is not convertible to int.")
183
+
184
+ @classmethod
185
+ def _validate_inner_cells(cls, cells, row_int: int) -> dict: # type: ignore[no-untyped-def]
186
+ """Validates that 'cells' is a dict mapping column letters to valid cell values
187
+ and normalizes the keys.
188
+
189
+ Helper method for when validating the `data` field.
190
+ """
191
+ if not isinstance(cells, dict):
192
+ raise TypeError(
193
+ f"Value for row '{row_int}' must be a dict mapping column letters to cell values."
194
+ )
195
+ new_inner = {}
196
+ for col_key, cell_value in cells.items():
197
+ if not isinstance(col_key, str):
198
+ raise TypeError(f"Column key '{col_key}' must be a string.")
199
+ col_string = col_key.upper()
200
+ if not col_string.isalpha():
201
+ raise TypeError(f"Column key '{col_key}' is invalid. Must be alphabetic.")
202
+ if not isinstance(cell_value, int | float | str | bool):
203
+ raise TypeError(
204
+ f"Cell value for {col_string}{row_int} must be an int, float, str, or bool."
205
+ )
206
+ new_inner[col_string] = cell_value
207
+ return new_inner
208
+
209
+ @field_validator("data", mode="before")
210
+ @classmethod
211
+ def validate_and_convert_keys(cls, value): # type: ignore[no-untyped-def]
212
+ """
213
+ Validates data when SheetDataInput is instantiated and converts it to the correct format.
214
+ Uses private helper methods to parse JSON, validate row keys, and validate inner cell data.
215
+ """
216
+ if value is None:
217
+ return {}
218
+
219
+ value = cls._parse_json_if_string(value)
220
+ if isinstance(value, dict):
221
+ new_value = {}
222
+ for row_key, cells in value.items():
223
+ row_int = cls._validate_row_key(row_key)
224
+ inner_cells = cls._validate_inner_cells(cells, row_int)
225
+ new_value[row_int] = inner_cells
226
+ return new_value
227
+
228
+ raise TypeError("data must be a dict or a valid JSON string representing a dict")
229
+
230
+ def json_data(self) -> str:
231
+ """
232
+ Serialize the sheet data to a JSON string.
233
+ """
234
+ return json.dumps(self.data)
235
+
236
+ @classmethod
237
+ def from_json(cls, json_str: str) -> "SheetDataInput":
238
+ """
239
+ Create a SheetData instance from a JSON string.
240
+ """
241
+ return cls.model_validate_json(json_str)
@@ -0,0 +1,4 @@
1
+ from arcade_google_sheets.tools.read import get_spreadsheet
2
+ from arcade_google_sheets.tools.write import create_spreadsheet, write_to_cell
3
+
4
+ __all__ = ["create_spreadsheet", "get_spreadsheet", "write_to_cell"]
@@ -0,0 +1,42 @@
1
+ from typing import Annotated
2
+
3
+ from arcade_tdk import ToolContext, ToolMetadataKey, tool
4
+ from arcade_tdk.auth import Google
5
+
6
+ from arcade_google_sheets.decorators import with_filepicker_fallback
7
+ from arcade_google_sheets.utils import (
8
+ build_sheets_service,
9
+ parse_get_spreadsheet_response,
10
+ )
11
+
12
+
13
+ @tool(
14
+ requires_auth=Google(
15
+ scopes=["https://www.googleapis.com/auth/drive.file"],
16
+ ),
17
+ requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL],
18
+ )
19
+ @with_filepicker_fallback
20
+ async def get_spreadsheet(
21
+ context: ToolContext,
22
+ spreadsheet_id: Annotated[str, "The id of the spreadsheet to get"],
23
+ ) -> Annotated[
24
+ dict,
25
+ "The spreadsheet properties and data for all sheets in the spreadsheet",
26
+ ]:
27
+ """
28
+ Get the user entered values and formatted values for all cells in all sheets in the spreadsheet
29
+ along with the spreadsheet's properties
30
+ """
31
+ service = build_sheets_service(context.get_auth_token_or_empty())
32
+
33
+ response = (
34
+ service.spreadsheets()
35
+ .get(
36
+ spreadsheetId=spreadsheet_id,
37
+ includeGridData=True,
38
+ fields="spreadsheetId,spreadsheetUrl,properties/title,sheets/properties,sheets/data/rowData/values/userEnteredValue,sheets/data/rowData/values/formattedValue,sheets/data/rowData/values/effectiveValue",
39
+ )
40
+ .execute()
41
+ )
42
+ return parse_get_spreadsheet_response(response)
@@ -0,0 +1,114 @@
1
+ from typing import Annotated
2
+
3
+ from arcade_tdk import ToolContext, tool
4
+ from arcade_tdk.auth import Google
5
+ from arcade_tdk.errors import RetryableToolError
6
+
7
+ from arcade_google_sheets.models import (
8
+ SheetDataInput,
9
+ Spreadsheet,
10
+ SpreadsheetProperties,
11
+ )
12
+ from arcade_google_sheets.utils import (
13
+ build_sheets_service,
14
+ create_sheet,
15
+ parse_write_to_cell_response,
16
+ validate_write_to_cell_params,
17
+ )
18
+
19
+
20
+ @tool(
21
+ requires_auth=Google(
22
+ scopes=["https://www.googleapis.com/auth/drive.file"],
23
+ )
24
+ )
25
+ def create_spreadsheet(
26
+ context: ToolContext,
27
+ title: Annotated[str, "The title of the new spreadsheet"] = "Untitled spreadsheet",
28
+ data: Annotated[
29
+ str | None,
30
+ "The data to write to the spreadsheet. A JSON string "
31
+ "(property names enclosed in double quotes) representing a dictionary that "
32
+ "maps row numbers to dictionaries that map column letters to cell values. "
33
+ "For example, data[23]['C'] would be the value of the cell in row 23, column C. "
34
+ "Type hint: dict[int, dict[str, Union[int, float, str, bool]]]",
35
+ ] = None,
36
+ ) -> Annotated[dict, "The created spreadsheet's id and title"]:
37
+ """Create a new spreadsheet with the provided title and data in its first sheet
38
+
39
+ Returns the newly created spreadsheet's id and title
40
+ """
41
+ service = build_sheets_service(context.get_auth_token_or_empty())
42
+
43
+ try:
44
+ sheet_data = SheetDataInput(data=data) # type: ignore[arg-type]
45
+ except Exception as e:
46
+ msg = "Invalid JSON or unexpected data format for parameter `data`"
47
+ raise RetryableToolError(
48
+ message=msg,
49
+ additional_prompt_content=f"{msg}: {e}",
50
+ retry_after_ms=100,
51
+ )
52
+
53
+ spreadsheet = Spreadsheet(
54
+ properties=SpreadsheetProperties(title=title),
55
+ sheets=[create_sheet(sheet_data)],
56
+ )
57
+
58
+ body = spreadsheet.model_dump()
59
+
60
+ response = (
61
+ service.spreadsheets()
62
+ .create(body=body, fields="spreadsheetId,spreadsheetUrl,properties/title")
63
+ .execute()
64
+ )
65
+
66
+ return {
67
+ "title": response["properties"]["title"],
68
+ "spreadsheetId": response["spreadsheetId"],
69
+ "spreadsheetUrl": response["spreadsheetUrl"],
70
+ }
71
+
72
+
73
+ @tool(
74
+ requires_auth=Google(
75
+ scopes=["https://www.googleapis.com/auth/drive.file"],
76
+ )
77
+ )
78
+ def write_to_cell(
79
+ context: ToolContext,
80
+ spreadsheet_id: Annotated[str, "The id of the spreadsheet to write to"],
81
+ column: Annotated[str, "The column string to write to. For example, 'A', 'F', or 'AZ'"],
82
+ row: Annotated[int, "The row number to write to"],
83
+ value: Annotated[str, "The value to write to the cell"],
84
+ sheet_name: Annotated[
85
+ str, "The name of the sheet to write to. Defaults to 'Sheet1'"
86
+ ] = "Sheet1",
87
+ ) -> Annotated[dict, "The status of the operation"]:
88
+ """
89
+ Write a value to a single cell in a spreadsheet.
90
+ """
91
+ service = build_sheets_service(context.get_auth_token_or_empty())
92
+ validate_write_to_cell_params(service, spreadsheet_id, sheet_name, column, row)
93
+
94
+ range_ = f"'{sheet_name}'!{column.upper()}{row}"
95
+ body = {
96
+ "range": range_,
97
+ "majorDimension": "ROWS",
98
+ "values": [[value]],
99
+ }
100
+
101
+ sheet_properties = (
102
+ service.spreadsheets()
103
+ .values()
104
+ .update(
105
+ spreadsheetId=spreadsheet_id,
106
+ range=range_,
107
+ valueInputOption="USER_ENTERED",
108
+ includeValuesInResponse=True,
109
+ body=body,
110
+ )
111
+ .execute()
112
+ )
113
+
114
+ return parse_write_to_cell_response(sheet_properties)
@@ -0,0 +1 @@
1
+ CellValue = int | float | str | bool
@@ -0,0 +1,548 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from arcade_tdk.errors import RetryableToolError, ToolExecutionError
5
+ from google.oauth2.credentials import Credentials
6
+ from googleapiclient.discovery import Resource, build
7
+
8
+ from arcade_google_sheets.constants import (
9
+ DEFAULT_SHEET_COLUMN_COUNT,
10
+ DEFAULT_SHEET_ROW_COUNT,
11
+ )
12
+ from arcade_google_sheets.enums import NumberFormatType
13
+ from arcade_google_sheets.models import (
14
+ CellData,
15
+ CellExtendedValue,
16
+ CellFormat,
17
+ GridData,
18
+ GridProperties,
19
+ NumberFormat,
20
+ RowData,
21
+ Sheet,
22
+ SheetDataInput,
23
+ SheetProperties,
24
+ )
25
+ from arcade_google_sheets.types import CellValue
26
+
27
+ logging.basicConfig(
28
+ level=logging.DEBUG,
29
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def build_sheets_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
36
+ """
37
+ Build a Sheets service object.
38
+ """
39
+ auth_token = auth_token or ""
40
+ return build("sheets", "v4", credentials=Credentials(auth_token))
41
+
42
+
43
+ def col_to_index(col: str) -> int:
44
+ """Convert a sheet's column string to a 0-indexed column index
45
+
46
+ Args:
47
+ col (str): The column string to convert. e.g., "A", "AZ", "QED"
48
+
49
+ Returns:
50
+ int: The 0-indexed column index.
51
+ """
52
+ result = 0
53
+ for char in col.upper():
54
+ result = result * 26 + (ord(char) - ord("A") + 1)
55
+ return result - 1
56
+
57
+
58
+ def index_to_col(index: int) -> str:
59
+ """Convert a 0-indexed column index to its corresponding column string
60
+
61
+ Args:
62
+ index (int): The 0-indexed column index to convert.
63
+
64
+ Returns:
65
+ str: The column string. e.g., "A", "AZ", "QED"
66
+ """
67
+ result = ""
68
+ index += 1
69
+ while index > 0:
70
+ index, rem = divmod(index - 1, 26)
71
+ result = chr(rem + ord("A")) + result
72
+ return result
73
+
74
+
75
+ def is_col_greater(col1: str, col2: str) -> bool:
76
+ """Determine if col1 represents a column that comes after col2 in a sheet
77
+
78
+ This comparison is based on:
79
+ 1. The length of the column string (longer means greater).
80
+ 2. Lexicographical comparison if both strings are the same length.
81
+
82
+ Args:
83
+ col1 (str): The first column string to compare.
84
+ col2 (str): The second column string to compare.
85
+
86
+ Returns:
87
+ bool: True if col1 comes after col2, False otherwise.
88
+ """
89
+ if len(col1) != len(col2):
90
+ return len(col1) > len(col2)
91
+ return col1.upper() > col2.upper()
92
+
93
+
94
+ def compute_sheet_data_dimensions(
95
+ sheet_data_input: SheetDataInput,
96
+ ) -> tuple[tuple[int, int], tuple[int, int]]:
97
+ """
98
+ Compute the dimensions of a sheet based on the data provided.
99
+
100
+ Args:
101
+ sheet_data_input (SheetDataInput):
102
+ The data to compute the dimensions of.
103
+
104
+ Returns:
105
+ tuple[tuple[int, int], tuple[int, int]]: The dimensions of the sheet. The first tuple
106
+ contains the row range (start, end) and the second tuple contains the column range
107
+ (start, end).
108
+ """
109
+ max_row = 0
110
+ min_row = 10_000_000 # max number of cells in a sheet
111
+ max_col_str = None
112
+ min_col_str = None
113
+
114
+ for key, row in sheet_data_input.data.items():
115
+ try:
116
+ row_num = int(key)
117
+ except ValueError:
118
+ continue
119
+ if row_num > max_row:
120
+ max_row = row_num
121
+ if row_num < min_row:
122
+ min_row = row_num
123
+
124
+ if isinstance(row, dict):
125
+ for col in row:
126
+ # Update max column string
127
+ if max_col_str is None or is_col_greater(col, max_col_str):
128
+ max_col_str = col
129
+ # Update min column string
130
+ if min_col_str is None or is_col_greater(min_col_str, col):
131
+ min_col_str = col
132
+
133
+ max_col_index = col_to_index(max_col_str) if max_col_str is not None else -1
134
+ min_col_index = col_to_index(min_col_str) if min_col_str is not None else 0
135
+
136
+ return (min_row, max_row), (min_col_index, max_col_index)
137
+
138
+
139
+ def create_sheet(sheet_data_input: SheetDataInput) -> Sheet:
140
+ """Create a Google Sheet from a dictionary of data.
141
+
142
+ Args:
143
+ sheet_data_input (SheetDataInput): The data to create the sheet from.
144
+
145
+ Returns:
146
+ Sheet: The created sheet.
147
+ """
148
+ (_, max_row), (min_col_index, max_col_index) = compute_sheet_data_dimensions(sheet_data_input)
149
+ sheet_data = create_sheet_data(sheet_data_input, min_col_index, max_col_index)
150
+ sheet_properties = create_sheet_properties(
151
+ row_count=max(DEFAULT_SHEET_ROW_COUNT, max_row),
152
+ column_count=max(DEFAULT_SHEET_COLUMN_COUNT, max_col_index + 1),
153
+ )
154
+
155
+ return Sheet(properties=sheet_properties, data=sheet_data)
156
+
157
+
158
+ def create_sheet_properties(
159
+ sheet_id: int = 1,
160
+ title: str = "Sheet1",
161
+ row_count: int = DEFAULT_SHEET_ROW_COUNT,
162
+ column_count: int = DEFAULT_SHEET_COLUMN_COUNT,
163
+ ) -> SheetProperties:
164
+ """Create a SheetProperties object
165
+
166
+ Args:
167
+ sheet_id (int): The ID of the sheet.
168
+ title (str): The title of the sheet.
169
+ row_count (int): The number of rows in the sheet.
170
+ column_count (int): The number of columns in the sheet.
171
+
172
+ Returns:
173
+ SheetProperties: The created sheet properties object.
174
+ """
175
+ return SheetProperties(
176
+ sheetId=sheet_id,
177
+ title=title,
178
+ gridProperties=GridProperties(rowCount=row_count, columnCount=column_count),
179
+ )
180
+
181
+
182
+ def group_contiguous_rows(row_numbers: list[int]) -> list[list[int]]:
183
+ """Groups a sorted list of row numbers into contiguous groups
184
+
185
+ A contiguous group is a list of row numbers that are consecutive integers.
186
+ For example, [1,2,3,5,6] is converted to [[1,2,3],[5,6]].
187
+
188
+ Args:
189
+ row_numbers (list[int]): The list of row numbers to group.
190
+
191
+ Returns:
192
+ list[list[int]]: The grouped row numbers.
193
+ """
194
+ if not row_numbers:
195
+ return []
196
+ groups = []
197
+ current_group = [row_numbers[0]]
198
+ for r in row_numbers[1:]:
199
+ if r == current_group[-1] + 1:
200
+ current_group.append(r)
201
+ else:
202
+ groups.append(current_group)
203
+ current_group = [r]
204
+ groups.append(current_group)
205
+ return groups
206
+
207
+
208
+ def create_cell_data(cell_value: CellValue) -> CellData:
209
+ """
210
+ Create a CellData object based on the type of cell_value.
211
+ """
212
+ if isinstance(cell_value, bool):
213
+ return _create_bool_cell(cell_value)
214
+ elif isinstance(cell_value, int):
215
+ return _create_int_cell(cell_value)
216
+ elif isinstance(cell_value, float):
217
+ return _create_float_cell(cell_value)
218
+ elif isinstance(cell_value, str):
219
+ return _create_string_cell(cell_value)
220
+
221
+
222
+ def _create_formula_cell(cell_value: str) -> CellData:
223
+ cell_val = CellExtendedValue(formulaValue=cell_value)
224
+ return CellData(userEnteredValue=cell_val)
225
+
226
+
227
+ def _create_currency_cell(cell_value: str) -> CellData:
228
+ value_without_symbol = cell_value[1:]
229
+ try:
230
+ num_value = int(value_without_symbol)
231
+ cell_format = CellFormat(
232
+ numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0")
233
+ )
234
+ cell_val = CellExtendedValue(numberValue=num_value)
235
+ return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
236
+ except ValueError:
237
+ try:
238
+ num_value = float(value_without_symbol) # type: ignore[assignment]
239
+ cell_format = CellFormat(
240
+ numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0.00")
241
+ )
242
+ cell_val = CellExtendedValue(numberValue=num_value)
243
+ return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
244
+ except ValueError:
245
+ return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
246
+
247
+
248
+ def _create_percent_cell(cell_value: str) -> CellData:
249
+ try:
250
+ num_value = float(cell_value[:-1].strip())
251
+ cell_format = CellFormat(
252
+ numberFormat=NumberFormat(type=NumberFormatType.PERCENT, pattern="0.00%")
253
+ )
254
+ cell_val = CellExtendedValue(numberValue=num_value)
255
+ return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
256
+ except ValueError:
257
+ return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
258
+
259
+
260
+ def _create_bool_cell(cell_value: bool) -> CellData:
261
+ return CellData(userEnteredValue=CellExtendedValue(boolValue=cell_value))
262
+
263
+
264
+ def _create_int_cell(cell_value: int) -> CellData:
265
+ cell_format = CellFormat(
266
+ numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0")
267
+ )
268
+ return CellData(
269
+ userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format
270
+ )
271
+
272
+
273
+ def _create_float_cell(cell_value: float) -> CellData:
274
+ cell_format = CellFormat(
275
+ numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0.00")
276
+ )
277
+ return CellData(
278
+ userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format
279
+ )
280
+
281
+
282
+ def _create_string_cell(cell_value: str) -> CellData:
283
+ if cell_value.startswith("="):
284
+ return _create_formula_cell(cell_value)
285
+ elif cell_value.startswith("$") and len(cell_value) > 1:
286
+ return _create_currency_cell(cell_value)
287
+ elif cell_value.endswith("%") and len(cell_value) > 1:
288
+ return _create_percent_cell(cell_value)
289
+
290
+ return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
291
+
292
+
293
+ def create_row_data(
294
+ row_data: dict[str, CellValue], min_col_index: int, max_col_index: int
295
+ ) -> RowData:
296
+ """Constructs RowData for a single row using the provided row_data.
297
+
298
+ Args:
299
+ row_data (dict[str, CellValue]): The data to create the row from.
300
+ min_col_index (int): The minimum column index from the SheetDataInput.
301
+ max_col_index (int): The maximum column index from the SheetDataInput.
302
+ """
303
+ row_cells = []
304
+ for col_idx in range(min_col_index, max_col_index + 1):
305
+ col_letter = index_to_col(col_idx)
306
+ if col_letter in row_data:
307
+ cell_data = create_cell_data(row_data[col_letter])
308
+ else:
309
+ cell_data = CellData(userEnteredValue=CellExtendedValue(stringValue=""))
310
+ row_cells.append(cell_data)
311
+ return RowData(values=row_cells)
312
+
313
+
314
+ def create_sheet_data(
315
+ sheet_data_input: SheetDataInput,
316
+ min_col_index: int,
317
+ max_col_index: int,
318
+ ) -> list[GridData]:
319
+ """Create grid data from SheetDataInput by grouping contiguous rows and processing cells.
320
+
321
+ Args:
322
+ sheet_data_input (SheetDataInput): The data to create the sheet from.
323
+ min_col_index (int): The minimum column index from the SheetDataInput.
324
+ max_col_index (int): The maximum column index from the SheetDataInput.
325
+
326
+ Returns:
327
+ list[GridData]: The created grid data.
328
+ """
329
+ row_numbers = list(sheet_data_input.data.keys())
330
+ if not row_numbers:
331
+ return []
332
+
333
+ sorted_rows = sorted(row_numbers)
334
+ groups = group_contiguous_rows(sorted_rows)
335
+
336
+ sheet_data = []
337
+ for group in groups:
338
+ rows_data = []
339
+ for r in group:
340
+ current_row_data = sheet_data_input.data.get(r, {})
341
+ row = create_row_data(current_row_data, min_col_index, max_col_index)
342
+ rows_data.append(row)
343
+ grid_data = GridData(
344
+ startRow=group[0] - 1, # convert to 0-indexed
345
+ startColumn=min_col_index,
346
+ rowData=rows_data,
347
+ )
348
+ sheet_data.append(grid_data)
349
+
350
+ return sheet_data
351
+
352
+
353
+ def parse_get_spreadsheet_response(api_response: dict) -> dict:
354
+ """
355
+ Parse the get spreadsheet Google Sheets API response into a structured dictionary.
356
+ """
357
+ properties = api_response.get("properties", {})
358
+ sheets = [parse_sheet(sheet) for sheet in api_response.get("sheets", [])]
359
+
360
+ return {
361
+ "title": properties.get("title", ""),
362
+ "spreadsheetId": api_response.get("spreadsheetId", ""),
363
+ "spreadsheetUrl": api_response.get("spreadsheetUrl", ""),
364
+ "sheets": sheets,
365
+ }
366
+
367
+
368
+ def parse_sheet(api_sheet: dict) -> dict:
369
+ """
370
+ Parse an individual sheet's data from the Google Sheets 'get spreadsheet'
371
+ API response into a structured dictionary.
372
+ """
373
+ props = api_sheet.get("properties", {})
374
+ grid_props = props.get("gridProperties", {})
375
+ cell_data = convert_api_grid_data_to_dict(api_sheet.get("data", []))
376
+
377
+ return {
378
+ "sheetId": props.get("sheetId"),
379
+ "title": props.get("title", ""),
380
+ "rowCount": grid_props.get("rowCount", 0),
381
+ "columnCount": grid_props.get("columnCount", 0),
382
+ "data": cell_data,
383
+ }
384
+
385
+
386
+ def extract_user_entered_cell_value(cell: dict) -> Any:
387
+ """
388
+ Extract the user entered value from a cell's 'userEnteredValue'.
389
+
390
+ Args:
391
+ cell (dict): A cell dictionary from the grid data.
392
+
393
+ Returns:
394
+ The extracted value if present, otherwise None.
395
+ """
396
+ user_val = cell.get("userEnteredValue", {})
397
+ for key in ["stringValue", "numberValue", "boolValue", "formulaValue"]:
398
+ if key in user_val:
399
+ return user_val[key]
400
+
401
+ return ""
402
+
403
+
404
+ def process_row(row: dict, start_column_index: int) -> dict:
405
+ """
406
+ Process a single row from grid data, converting non-empty cells into a dictionary
407
+ that maps column letters to cell values.
408
+
409
+ Args:
410
+ row (dict): A row from the grid data.
411
+ start_column_index (int): The starting column index for this row.
412
+
413
+ Returns:
414
+ dict: A mapping of column letters to cell values for non-empty cells.
415
+ """
416
+ row_result = {}
417
+ for j, cell in enumerate(row.get("values", [])):
418
+ column_index = start_column_index + j
419
+ column_string = index_to_col(column_index)
420
+ user_entered_cell_value = extract_user_entered_cell_value(cell)
421
+ formatted_cell_value = cell.get("formattedValue", "")
422
+
423
+ if user_entered_cell_value != "" or formatted_cell_value != "":
424
+ row_result[column_string] = {
425
+ "userEnteredValue": user_entered_cell_value,
426
+ "formattedValue": formatted_cell_value,
427
+ }
428
+
429
+ return row_result
430
+
431
+
432
+ def convert_api_grid_data_to_dict(grids: list[dict]) -> dict:
433
+ """
434
+ Convert a list of grid data dictionaries from the 'get spreadsheet' API
435
+ response into a structured cell dictionary.
436
+
437
+ The returned dictionary maps row numbers to sub-dictionaries that map column letters
438
+ (e.g., 'A', 'B', etc.) to their corresponding non-empty cell values.
439
+
440
+ Args:
441
+ grids (list[dict]): The list of grid data dictionaries from the API.
442
+
443
+ Returns:
444
+ dict: A dictionary mapping row numbers to dictionaries of column letter/value pairs.
445
+ Only includes non-empty rows and non-empty cells.
446
+ """
447
+ result = {}
448
+ for grid in grids:
449
+ start_row = grid.get("startRow", 0)
450
+ start_column = grid.get("startColumn", 0)
451
+
452
+ for i, row in enumerate(grid.get("rowData", []), start=1):
453
+ current_row = start_row + i
454
+ row_data = process_row(row, start_column)
455
+
456
+ if row_data:
457
+ result[current_row] = row_data
458
+
459
+ return dict(sorted(result.items()))
460
+
461
+
462
+ def validate_write_to_cell_params( # type: ignore[no-any-unimported]
463
+ service: Resource,
464
+ spreadsheet_id: str,
465
+ sheet_name: str,
466
+ column: str,
467
+ row: int,
468
+ ) -> None:
469
+ """Validates the input parameters for the write to cell tool.
470
+
471
+ Args:
472
+ service (Resource): The Google Sheets service.
473
+ spreadsheet_id (str): The ID of the spreadsheet provided to the tool.
474
+ sheet_name (str): The name of the sheet provided to the tool.
475
+ column (str): The column to write to provided to the tool.
476
+ row (int): The row to write to provided to the tool.
477
+
478
+ Raises:
479
+ RetryableToolError:
480
+ If the sheet name is not found in the spreadsheet
481
+ ToolExecutionError:
482
+ If the column is not alphabetical
483
+ If the row is not a positive number
484
+ If the row is out of bounds for the sheet
485
+ If the column is out of bounds for the sheet
486
+ """
487
+ if not column.isalpha():
488
+ raise ToolExecutionError(
489
+ message=(
490
+ f"Invalid column name {column}. "
491
+ "It must be a non-empty string containing only letters"
492
+ ),
493
+ )
494
+
495
+ if row < 1:
496
+ raise ToolExecutionError(
497
+ message=(f"Invalid row number {row}. It must be a positive integer greater than 0."),
498
+ )
499
+
500
+ sheet_properties = (
501
+ service.spreadsheets()
502
+ .get(
503
+ spreadsheetId=spreadsheet_id,
504
+ includeGridData=True,
505
+ fields="sheets/properties/title,sheets/properties/gridProperties/rowCount,sheets/properties/gridProperties/columnCount",
506
+ )
507
+ .execute()
508
+ )
509
+ sheet_names = [sheet["properties"]["title"] for sheet in sheet_properties["sheets"]]
510
+ sheet_row_count = sheet_properties["sheets"][0]["properties"]["gridProperties"]["rowCount"]
511
+ sheet_column_count = sheet_properties["sheets"][0]["properties"]["gridProperties"][
512
+ "columnCount"
513
+ ]
514
+
515
+ if sheet_name not in sheet_names:
516
+ raise RetryableToolError(
517
+ message=f"Sheet name {sheet_name} not found in spreadsheet with id {spreadsheet_id}",
518
+ additional_prompt_content=f"Sheet names in the spreadsheet: {sheet_names}",
519
+ retry_after_ms=100,
520
+ )
521
+
522
+ if row > sheet_row_count:
523
+ raise ToolExecutionError(
524
+ message=(
525
+ f"Row {row} is out of bounds for sheet {sheet_name} "
526
+ f"in spreadsheet with id {spreadsheet_id}. "
527
+ f"Sheet only has {sheet_row_count} rows which is less than the requested row {row}"
528
+ )
529
+ )
530
+
531
+ if col_to_index(column) > sheet_column_count:
532
+ raise ToolExecutionError(
533
+ message=(
534
+ f"Column {column} is out of bounds for sheet {sheet_name} "
535
+ f"in spreadsheet with id {spreadsheet_id}. "
536
+ f"Sheet only has {sheet_column_count} columns which "
537
+ f"is less than the requested column {column}"
538
+ )
539
+ )
540
+
541
+
542
+ def parse_write_to_cell_response(response: dict) -> dict:
543
+ return {
544
+ "spreadsheetId": response["spreadsheetId"],
545
+ "sheetTitle": response["updatedData"]["range"].split("!")[0],
546
+ "updatedCell": response["updatedData"]["range"].split("!")[1],
547
+ "value": response["updatedData"]["values"][0][0],
548
+ }
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: arcade_google_sheets
3
+ Version: 2.0.0rc1
4
+ Summary: Arcade.dev LLM tools for Google Sheets
5
+ Author-email: Arcade <dev@arcade.dev>
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: arcade-tdk<3.0.0,>=2.0.0
9
+ Requires-Dist: google-api-core<3.0.0,>=2.19.1
10
+ Requires-Dist: google-api-python-client<3.0.0,>=2.137.0
11
+ Requires-Dist: google-auth-httplib2<1.0.0,>=0.2.0
12
+ Requires-Dist: google-auth<3.0.0,>=2.32.0
13
+ Requires-Dist: googleapis-common-protos<2.0.0,>=1.63.2
14
+ Provides-Extra: dev
15
+ Requires-Dist: arcade-ai[evals]<3.0.0,>=2.0.0rc1; extra == 'dev'
16
+ Requires-Dist: arcade-serve<3.0.0,>=2.0.0; extra == 'dev'
17
+ Requires-Dist: mypy<1.6.0,>=1.5.1; extra == 'dev'
18
+ Requires-Dist: pre-commit<3.5.0,>=3.4.0; extra == 'dev'
19
+ Requires-Dist: pytest-asyncio<0.25.0,>=0.24.0; extra == 'dev'
20
+ Requires-Dist: pytest-cov<4.1.0,>=4.0.0; extra == 'dev'
21
+ Requires-Dist: pytest-mock<3.12.0,>=3.11.1; extra == 'dev'
22
+ Requires-Dist: pytest<8.4.0,>=8.3.0; extra == 'dev'
23
+ Requires-Dist: ruff<0.8.0,>=0.7.4; extra == 'dev'
24
+ Requires-Dist: tox<4.12.0,>=4.11.1; extra == 'dev'
@@ -0,0 +1,15 @@
1
+ arcade_google_sheets/__init__.py,sha256=FB9h_cws_gu3UJp32GWlqvBQyAOb77JfAJNSSBqz-Jk,177
2
+ arcade_google_sheets/constants.py,sha256=4tQOrQ1YagSklJSSw5Eq21XCcFCJdO7lso5SqWIdrPI,63
3
+ arcade_google_sheets/decorators.py,sha256=QMqfvSXaFBoxYJrz69EGeMdAxF0V7JPReVXfp73Nf3Y,753
4
+ arcade_google_sheets/enums.py,sha256=_gZxlgciXK-_Sg-62lTv6JFpmxWV7obH9VWE-s1zjug,942
5
+ arcade_google_sheets/file_picker.py,sha256=kGfUVfH5QVlIW1sL-_gAwPokt7TwVEcPk3Vnk53GKUE,2005
6
+ arcade_google_sheets/models.py,sha256=VQy3L_Acch1MEM2RkTe-Qp_AEU-cb0JciLJ-0Ci87aw,7613
7
+ arcade_google_sheets/types.py,sha256=R-rCRcyFqDZx3jgl_kWeCliqC8fHuZ8ub_LQ2KoU2AE,37
8
+ arcade_google_sheets/utils.py,sha256=CqQJPwXP_QoMJG18LrltG4sblTcFe4Mu5psY4U2nsvc,18412
9
+ arcade_google_sheets/tools/__init__.py,sha256=TPlitJn1VJffCXFkpOtoYXNsaEFkpujQzsYvuikCe4U,209
10
+ arcade_google_sheets/tools/read.py,sha256=Fh9nh8ISHBgf6akixVYQIt3TihrmW7P9WW8yeb-njhI,1416
11
+ arcade_google_sheets/tools/write.py,sha256=gmbErdBbBKUEPxGjCWDpMJ9pMWRAvoEjApIXFQax6Z4,3598
12
+ arcade_google_sheets-2.0.0rc1.dist-info/METADATA,sha256=7i1LqljWn_XKRIbID_4i-cF0qigW2_o0xGQbrs0vOgM,1067
13
+ arcade_google_sheets-2.0.0rc1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ arcade_google_sheets-2.0.0rc1.dist-info/licenses/LICENSE,sha256=XjKuCk1TG4bFrY-8x79oGMmNqrS4TP7c_Zv4-TrMWQY,1063
15
+ arcade_google_sheets-2.0.0rc1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Arcade
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.