# PR Description ## Split toolkits This PR splits the `Microsoft`, `Google`, and `Search` toolkits into multiple toolkits each. * `Microsoft` --> `OutlookCalendar`, `OutlookMail`. * `Google` -----> `GoogleCalendar`, `GoogleContacts`, `GoogleDocs`, `GoogleDrive`, `Gmail`, `GoogleSheets` * `Search` -----> `GoogleFinance`, `GoogleFlights`, `GoogleHotels`, `GoogleJobs`, `GoogleMaps`, `GoogleNews`, `GoogleSearch`, `GoogleShopping`, `Walmart`, `Youtube` > The original monolithic toolkits (`Microsoft`, `Google`, `Search`) are not removed in this PR. The plan is to keep those toolkits around while we > 1. Stop documenting the toolkits, > 2. Stop displaying the toolkits in the dashboard, and > 3. Help customers migrate over to the new split toolkits. ## Rename toolkits This PR renames the following toolkits * `Web` ------------> `Firecrawl` * `CodeSandbox` ---> `E2B` > The `Web` and `CodeSandbox` toolkits are not removed in this PR. The plan is to keep them around while we > 1. Stop documenting the toolkits, > 2. Stop displaying the toolkits in the dashboard, and > 3. Help customers migrate over to the new renamed toolkits. ## Rename tools Since toolkit names were changed, this called for some tools to be renamed as well. * `GoogleSearch.SearchGoogle` ----------------> `GoogleSearch.Search` * `GoogleShopping.SearchShoppingProducts` ---> `GoogleShopping.SearchProducts` * `Walmart.SearchWalmartProducts` ------------> `Walmart.SearchProducts` * `Walmart.GetWalmartProductDetails` ---------> `Walmart.GetProductDetails` * `Youtube.SearchYoutubeVideos` --------------> `Youtube.SearchForVideos` ## Google File Picker Improvements to the Google File Picker experience were also added in this PR. The following tools will ALWAYS provide llm_instructions in their response to "let the end-user know that they have the option to select more files via the file picker url if they want to": * `GoogleDocs.SearchDocuments` * `GoogleDocs.SearchAndRetrieveDocuments` * `GoogleDrive.GetFileTreeStructure` The following tools will only provide the file picker URL if a 404 or 403 from the Google API: * `GoogleDocs.InsertTextAtEndOfDocument` * `GoogleDocs.GetDocumentById` * `GoogleSheets.GetSpreadsheet` * `GoogleSheets.WriteToCell` Also, a standalone `GoogleDrive.GenerateGoogleFilePickerUrl` tool exists. ## Other * The `SearchDocuments` and `SearchAndRetrieveDocuments` tools used to be organized within the Drive portion of the Google toolkit, but I moved these into the new GoogleDocs toolkit because they are specific to Docs. # Progress - [x] `OutlookCalendar` - [x] `OutlookMail` - [x] `GoogleFinance` - [x] `GoogleFlights` - [x] `GoogleHotels` - [x] `GoogleJobs` - [x] `GoogleMaps` - [x] `GoogleNews` - [x] `GoogleSearch` - [x] `GoogleShopping` - [x] `Walmart` - [x] `Youtube` - [x] `GoogleCalendar` - [x] `GoogleContacts` - [x] `GoogleDocs` - [x] `GoogleDrive` - [x] `Gmail` - [x] `GoogleSheets` - [x] `Firecrawl` - [x] `E2B` - [x] File picker # Discussion * Repeated code is a consequence of splitting toolkits that use the same provider. I am open to any ideas that would allow multiple toolkits to reference common code. Comment your ideas in this PR.
542 lines
17 KiB
Python
542 lines
17 KiB
Python
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
|
|
|
|
from arcade_google_sheets.enums import NumberFormatType
|
|
from arcade_google_sheets.models import (
|
|
CellData,
|
|
CellExtendedValue,
|
|
RowData,
|
|
SheetDataInput,
|
|
)
|
|
from arcade_google_sheets.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, strict=False):
|
|
assert cell.userEnteredValue == expected.userEnteredValue
|
|
|
|
|
|
def test_create_sheet_data():
|
|
from arcade_google_sheets.models import CellData, CellExtendedValue, SheetDataInput
|
|
from arcade_google_sheets.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, strict=False):
|
|
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, strict=False):
|
|
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, strict=False):
|
|
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_sheets.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_sheets.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_sheets.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_sheets.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)
|