| 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.
542 lines
17 KiB
Python
542 lines
17 KiB
Python
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)
|