From 93ffc1e3312abc5f3a0d9c1aab63c093580d08a0 Mon Sep 17 00:00:00 2001 From: Alexandre Lavigne Date: Mon, 3 Jun 2024 00:36:59 +0200 Subject: [PATCH 01/11] [feature] Add 'expand_table' feature Add a new feature that allows a user to expand a cell range into a table. the expand will look for the right most cell with adjacent value. the expand will look for the bottom most cell with adjacent value. the expand will table down from top left celle range to bottom right value. closes #1414 Signed-off-by: Alexandre Lavigne --- gspread/utils.py | 99 ++++++++++++++++++++++++++++++++++++++++++++ gspread/worksheet.py | 47 +++++++++++++++++++++ tests/utils_test.py | 39 +++++++++++++++++ 3 files changed, 185 insertions(+) diff --git a/gspread/utils.py b/gspread/utils.py index 11e848371..991a6b18d 100644 --- a/gspread/utils.py +++ b/gspread/utils.py @@ -168,6 +168,12 @@ class ValidationConditionType(StrEnum): filter_expression = "FILTER_EXPRESSION" +class TableDirection(StrEnum): + table = "TABLE" + down = "DOWN" + right = "RIGHT" + + def convert_credentials(credentials: Credentials) -> Credentials: module = credentials.__module__ cls = credentials.__class__.__name__ @@ -979,6 +985,99 @@ def to_records( return [dict(zip(headers, row)) for row in values] +def _expand_right(values: List[List[str]], start: int, end: int, row: int) -> int: + # find rightmost value + for column in range(start, end): + if values[row][column] == "": + return column + + return end + + +def _expand_bottom(values: List[List[str]], start: int, end: int, col: int) -> int: + # find the bottommost value + for rows in range(start, end): + print("bottom check row: {} col {}".format(rows, col)) + if values[rows][col] == "": + return rows + + return end + + +def find_table( + values: List[List[str]], direction: TableDirection, start_range: str +) -> List[List[str]]: + """Expands a list of values based on non-null adjacent cells. + + Expand can be done in 3 directions defined in :class:`~gspread.utils.TableDirection` + + * ``TableDirection.right``: expands only same row as starting cell on the right side up to + first null/empty cell + * ``TableDirection.down``: expands only same column as starting cell to the bottom up to + first null/empty cell + * ``TableDirection.table``: expands in both direction, first right then down. + + Regardless of the direction this function always returns a matrix of data, even if it has + only one column. + + Example:: + + values = [ + ['', '', '', '', '' ], + ['', 'B2', 'C2', '', 'E2'], + ['', 'B3', 'C3', '', 'E3'], + ['', '' , '' , '', 'E4'], + ] + >>> utils.find_table(TableDirection.table, 'B2') + [ + ['B2', 'C2'], + ['B3', 'C3'], + ] + + + .. note:: + + the ``TableDirection.table`` will first look right, then look down. + Any empty value in the midle of the table will be ignored. + + :param list[list] values: values where to find the table. + :param gspread.utils.TableDirection direction: the expand direction. + :param str start_range: the starting cell range. + :rtype list(list): the resulting matrix + """ + row, col = a1_to_rowcol(start_range) + + # a1_to_rowcol returns coordinates starting form 1 + row -= 1 + col -= 1 + + if direction == TableDirection.down: + rightMost = col + 1 + bottomMost = _expand_bottom(values, row, len(values), col) + + if direction == TableDirection.right: + rightMost = _expand_right(values, col, len(values[row]), row) + bottomMost = row + 1 + + if direction == TableDirection.table: + rightMost = _expand_right(values, col, len(values[row]), row) + print("found right: {}".format(rightMost)) + + checkColumn = rightMost + if checkColumn != 0: + checkColumn -= 1 + + bottomMost = _expand_bottom(values, row, len(values), checkColumn) + + result = [] + + # build resulting array + for rows in values[row:bottomMost]: + result.append(rows[col:rightMost]) + + return result + + # SHOULD NOT BE NEEDED UNTIL NEXT MAJOR VERSION # DEPRECATION_WARNING_TEMPLATE = ( # "[Deprecated][in version {v_deprecated}]: {msg_deprecated}" diff --git a/gspread/worksheet.py b/gspread/worksheet.py index 6a6783b80..c8b2e5e36 100644 --- a/gspread/worksheet.py +++ b/gspread/worksheet.py @@ -41,6 +41,7 @@ PasteOrientation, PasteType, T, + TableDirection, ValidationConditionType, ValueInputOption, ValueRenderOption, @@ -53,6 +54,7 @@ convert_colors_to_hex_value, convert_hex_to_colors_dict, fill_gaps, + find_table, finditem, get_a1_from_absolute_range, is_full_a1_notation, @@ -3336,3 +3338,48 @@ def add_validation( } return self.client.batch_update(self.spreadsheet_id, body) + + def expand_table( + self, + direction: TableDirection, + start_range: str = "A1", + ) -> List[List[str]]: + """Expands a cell range based on non-null adjacent cells. + + Expand can be done in 3 directions defined in :class:`~gspread.utils.TableDirection` + + * ``TableDirection.right``: expands only same row as starting cell on the right side up to + first null/empty cell + * ``TableDirection.down``: expands only same column as starting cell to the bottom up to + first null/empty cell + * ``TableDirection.table``: expands in both direction, first right then down. + + Regardless of the direction this function always returns a matrix of data, even if it has + only one column. + + Example:: + + values = [ + ['', '', '', '', '' ], + ['', 'B2', 'C2', '', 'E2'], + ['', 'B3', 'C3', '', 'E3'], + ['', '' , '' , '', 'E4'], + ] + >>> worksheet.expand_table(TableDirection.table, 'B2') + [ + ['B2', 'C2'], + ['B3', 'C3'], + ] + + + .. note:: + + the ``TableDirection.table`` will first look right, then look down. + Any empty value in the midle of the table will be ignored. + + :param gspread.utils.TableDirection direction: the expand direction + :param str start_range: the starting cell range. + :rtype list(list): the resulting matrix + """ + + return find_table(self.get(), direction, start_range) diff --git a/tests/utils_test.py b/tests/utils_test.py index 187e74eb0..9d40e4b82 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -503,3 +503,42 @@ def test_to_records(self): # given key are unordered # but they must match a value from the given input values self.assertIn(record[key], values[i]) + + def test_find_table(self): + """Test find table function""" + + values = [ + ["A1", "B1", "C1", "D1"], + ["", "B2", "C2", "", "D2"], + ["", "B3", "C3", "", "D3"], + ["", "", "", "", "D4"], + ] + + # table = utils.find_table(values, utils.TableDirection.table, "B2") + # right = utils.find_table(values, utils.TableDirection.right, "B2") + # down = utils.find_table(values, utils.TableDirection.down, "B2") + single = utils.find_table(values, utils.TableDirection.table, "C3") + no_values = utils.find_table(values, utils.TableDirection.table, "A2") + + # table_values = [ + # ["B2", "C2"], + # ["B3", "C3"], + # ] + # for row in range(len(table)): + # self.assertListEqual(table[row], table_values[row]) + + # right_values = [ + # ["B2", "C2"], + # ] + # for row in range(len(right)): + # self.assertListEqual(right[row], right_values[row]) + + # bottom_values = [ + # ["B2"], + # ["B3"], + # ] + # for row in range(len(down)): + # self.assertListEqual(down[row], bottom_values[row]) + + self.assertEqual(single[0][0], "C3") + self.assertEqual(no_values, []) From 90cd8e23bd74f4a9d3d883b7ce9872dbfab47f83 Mon Sep 17 00:00:00 2001 From: Alexandre Lavigne Date: Thu, 13 Jun 2024 23:53:35 +0200 Subject: [PATCH 02/11] fixup! [feature] Add 'expand_table' feature --- gspread/utils.py | 29 ++++++++++++++++----- gspread/worksheet.py | 33 ++++++++++++++--------- tests/utils_test.py | 62 ++++++++++++++++++++++++++------------------ 3 files changed, 81 insertions(+), 43 deletions(-) diff --git a/gspread/utils.py b/gspread/utils.py index 991a6b18d..5f332fa33 100644 --- a/gspread/utils.py +++ b/gspread/utils.py @@ -986,7 +986,15 @@ def to_records( def _expand_right(values: List[List[str]], start: int, end: int, row: int) -> int: - # find rightmost value + """This is a private function, returning the column index of the first empty cell + on the given row. + + Search starts from ``start`` index column. + Search ends on ``end`` index column. + Searches only in the row pointed by ``row``. + + If no empty value is found, it will return the given ``end`` index. + """ for column in range(start, end): if values[row][column] == "": return column @@ -995,9 +1003,16 @@ def _expand_right(values: List[List[str]], start: int, end: int, row: int) -> in def _expand_bottom(values: List[List[str]], start: int, end: int, col: int) -> int: - # find the bottommost value + """This is a private function, returning the row index of the first empty cell + on the given column. + + Search starts from ``start`` index row. + Search ends on ``end`` index row. + Searches only in the column pointed by ``col``. + + If no empty value is found, it will return the given ``end`` index. + """ for rows in range(start, end): - print("bottom check row: {} col {}".format(rows, col)) if values[rows][col] == "": return rows @@ -1005,7 +1020,9 @@ def _expand_bottom(values: List[List[str]], start: int, end: int, col: int) -> i def find_table( - values: List[List[str]], direction: TableDirection, start_range: str + values: List[List[str]], + start_range: str, + direction: TableDirection = TableDirection.table, ) -> List[List[str]]: """Expands a list of values based on non-null adjacent cells. @@ -1038,7 +1055,8 @@ def find_table( .. note:: the ``TableDirection.table`` will first look right, then look down. - Any empty value in the midle of the table will be ignored. + It will not check cells located inside the table. This could lead to + potential empty values located in the middle of the table. :param list[list] values: values where to find the table. :param gspread.utils.TableDirection direction: the expand direction. @@ -1061,7 +1079,6 @@ def find_table( if direction == TableDirection.table: rightMost = _expand_right(values, col, len(values[row]), row) - print("found right: {}".format(rightMost)) checkColumn = rightMost if checkColumn != 0: diff --git a/gspread/worksheet.py b/gspread/worksheet.py index c8b2e5e36..b593de3fa 100644 --- a/gspread/worksheet.py +++ b/gspread/worksheet.py @@ -3339,10 +3339,12 @@ def add_validation( return self.client.batch_update(self.spreadsheet_id, body) - def expand_table( + def expand( self, - direction: TableDirection, - start_range: str = "A1", + top_left_range_name: str = "A1", + direction: TableDirection = TableDirection.table, + value_render_option: Optional[ValueRenderOption] = None, + date_time_render_option: Optional[DateTimeOption] = None, ) -> List[List[str]]: """Expands a cell range based on non-null adjacent cells. @@ -3360,26 +3362,33 @@ def expand_table( Example:: values = [ - ['', '', '', '', '' ], - ['', 'B2', 'C2', '', 'E2'], - ['', 'B3', 'C3', '', 'E3'], - ['', '' , '' , '', 'E4'], + ['', '', '', '' , '' , ''], + ['', 'B2', 'C2', 'D2', '' , 'F2'], + ['', 'B3', '' , 'D3', '' , 'F3'], + ['', 'B4', 'C4', 'D4', '' , 'F4'], + ['', '' , '' , '' , '' , 'F5'], ] >>> worksheet.expand_table(TableDirection.table, 'B2') [ - ['B2', 'C2'], - ['B3', 'C3'], + ['B2', 'C2', 'D2], + ['B3', '' , 'D3'], + ['B4', 'C4', 'D4'], ] .. note:: - the ``TableDirection.table`` will first look right, then look down. - Any empty value in the midle of the table will be ignored. + the ``TableDirection.table`` will first look right, then look down. + It will not check cells located inside the table. This could lead to + potential empty values located in the middle of the table. :param gspread.utils.TableDirection direction: the expand direction :param str start_range: the starting cell range. :rtype list(list): the resulting matrix """ - return find_table(self.get(), direction, start_range) + values = self.get( + value_render_option=value_render_option, + date_time_render_option=date_time_render_option, + ) + return find_table(values, top_left_range_name, direction) diff --git a/tests/utils_test.py b/tests/utils_test.py index 9d40e4b82..47eb33fd6 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -514,31 +514,43 @@ def test_find_table(self): ["", "", "", "", "D4"], ] - # table = utils.find_table(values, utils.TableDirection.table, "B2") - # right = utils.find_table(values, utils.TableDirection.right, "B2") - # down = utils.find_table(values, utils.TableDirection.down, "B2") - single = utils.find_table(values, utils.TableDirection.table, "C3") - no_values = utils.find_table(values, utils.TableDirection.table, "A2") - - # table_values = [ - # ["B2", "C2"], - # ["B3", "C3"], - # ] - # for row in range(len(table)): - # self.assertListEqual(table[row], table_values[row]) - - # right_values = [ - # ["B2", "C2"], - # ] - # for row in range(len(right)): - # self.assertListEqual(right[row], right_values[row]) - - # bottom_values = [ - # ["B2"], - # ["B3"], - # ] - # for row in range(len(down)): - # self.assertListEqual(down[row], bottom_values[row]) + table = utils.find_table( + values, + "B2", + utils.TableDirection.table, + ) + right = utils.find_table( + values, + "B2", + utils.TableDirection.right, + ) + down = utils.find_table( + values, + "B2", + utils.TableDirection.down, + ) + single = utils.find_table(values, "C3", utils.TableDirection.table) + no_values = utils.find_table(values, "A2", utils.TableDirection.table) + + table_values = [ + ["B2", "C2"], + ["B3", "C3"], + ] + for row in range(len(table)): + self.assertListEqual(table[row], table_values[row]) + + right_values = [ + ["B2", "C2"], + ] + for row in range(len(right)): + self.assertListEqual(right[row], right_values[row]) + + bottom_values = [ + ["B2"], + ["B3"], + ] + for row in range(len(down)): + self.assertListEqual(down[row], bottom_values[row]) self.assertEqual(single[0][0], "C3") self.assertEqual(no_values, []) From 9ec1a676e7ce537803c42c54b8076df70cce778f Mon Sep 17 00:00:00 2001 From: Alexandre Lavigne Date: Thu, 27 Jun 2024 00:10:04 +0200 Subject: [PATCH 03/11] fixup! [feature] Add 'expand_table' feature --- gspread/utils.py | 24 +++++++++++++++++++----- gspread/worksheet.py | 23 +++++++++++------------ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/gspread/utils.py b/gspread/utils.py index 5f332fa33..74c5af007 100644 --- a/gspread/utils.py +++ b/gspread/utils.py @@ -996,6 +996,10 @@ def _expand_right(values: List[List[str]], start: int, end: int, row: int) -> in If no empty value is found, it will return the given ``end`` index. """ for column in range(start, end): + # in case the given row is smaller that what is being asked + if column >= len(values[row]): + return len(values[row]) - 1 + if values[row][column] == "": return column @@ -1013,6 +1017,14 @@ def _expand_bottom(values: List[List[str]], start: int, end: int, col: int) -> i If no empty value is found, it will return the given ``end`` index. """ for rows in range(start, end): + # in case we try to look further than last row + if rows >= len(values): + return len(values) - 1 + + # this row is smaller than the others, just keep looking + if col >= len(values[rows]): + continue + if values[rows][col] == "": return rows @@ -1028,11 +1040,9 @@ def find_table( Expand can be done in 3 directions defined in :class:`~gspread.utils.TableDirection` - * ``TableDirection.right``: expands only same row as starting cell on the right side up to - first null/empty cell - * ``TableDirection.down``: expands only same column as starting cell to the bottom up to - first null/empty cell - * ``TableDirection.table``: expands in both direction, first right then down. + * ``TableDirection.right``: expands right until the first empty cell + * ``TableDirection.down``: expands down until the first empty cell + * ``TableDirection.table``: expands right until the first empty cell, then down until the first empty cell Regardless of the direction this function always returns a matrix of data, even if it has only one column. @@ -1058,6 +1068,10 @@ def find_table( It will not check cells located inside the table. This could lead to potential empty values located in the middle of the table. + .. warning:: + + Given values must be padded with `''` empty values. + :param list[list] values: values where to find the table. :param gspread.utils.TableDirection direction: the expand direction. :param str start_range: the starting cell range. diff --git a/gspread/worksheet.py b/gspread/worksheet.py index b593de3fa..17f7c60b8 100644 --- a/gspread/worksheet.py +++ b/gspread/worksheet.py @@ -3343,18 +3343,14 @@ def expand( self, top_left_range_name: str = "A1", direction: TableDirection = TableDirection.table, - value_render_option: Optional[ValueRenderOption] = None, - date_time_render_option: Optional[DateTimeOption] = None, ) -> List[List[str]]: """Expands a cell range based on non-null adjacent cells. Expand can be done in 3 directions defined in :class:`~gspread.utils.TableDirection` - * ``TableDirection.right``: expands only same row as starting cell on the right side up to - first null/empty cell - * ``TableDirection.down``: expands only same column as starting cell to the bottom up to - first null/empty cell - * ``TableDirection.table``: expands in both direction, first right then down. + * ``TableDirection.right``: expands right until the first empty cell + * ``TableDirection.down``: expands down until the first empty cell + * ``TableDirection.table``: expands right until the first empty cell, then down until the first empty cell Regardless of the direction this function always returns a matrix of data, even if it has only one column. @@ -3382,13 +3378,16 @@ def expand( It will not check cells located inside the table. This could lead to potential empty values located in the middle of the table. + .. note:: + + when it is necessary to use non-default options for :meth:`~gspread.worksheet.Worksheet.get`, + please get the data first using desired options then use the function + :func:`gspread.utils.find_table` to extract the desired table. + + :param str top_left_range_name: the top left corner of the table to expand. :param gspread.utils.TableDirection direction: the expand direction - :param str start_range: the starting cell range. :rtype list(list): the resulting matrix """ - values = self.get( - value_render_option=value_render_option, - date_time_render_option=date_time_render_option, - ) + values = self.get(pad_values=True) return find_table(values, top_left_range_name, direction) From 629aec4f8397fa0858432fbb37aa451f01468daa Mon Sep 17 00:00:00 2001 From: alifeee Date: Sat, 29 Jun 2024 16:54:31 +0100 Subject: [PATCH 04/11] add some more tests for "edge" cases --- tests/utils_test.py | 107 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 9 deletions(-) diff --git a/tests/utils_test.py b/tests/utils_test.py index 47eb33fd6..6bd10fa2d 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -504,9 +504,8 @@ def test_to_records(self): # but they must match a value from the given input values self.assertIn(record[key], values[i]) - def test_find_table(self): - """Test find table function""" - + def test_find_table_simple(self): + """Test find table with basic case""" values = [ ["A1", "B1", "C1", "D1"], ["", "B2", "C2", "", "D2"], @@ -536,21 +535,111 @@ def test_find_table(self): ["B2", "C2"], ["B3", "C3"], ] - for row in range(len(table)): - self.assertListEqual(table[row], table_values[row]) + for rowindex, row in enumerate(table): + self.assertListEqual(row, table_values[rowindex]) right_values = [ ["B2", "C2"], ] - for row in range(len(right)): - self.assertListEqual(right[row], right_values[row]) + for rowindex, row in enumerate(right): + self.assertListEqual(row, right_values[rowindex]) bottom_values = [ ["B2"], ["B3"], ] - for row in range(len(down)): - self.assertListEqual(down[row], bottom_values[row]) + for rowindex, row in enumerate(down): + self.assertListEqual(row, bottom_values[rowindex]) self.assertEqual(single[0][0], "C3") self.assertEqual(no_values, []) + + def test_find_table_header_gap(self): + """Test find table with gap in header""" + values = [ + ["A1", "", "C1", ""], + ["A2", "B2", "C2", ""], + ["A3", "B3", "C3", ""], + ["", "", "", ""], + ] + expected_table = [ + ["A1"], + ["A2"], + ["A3"], + ] + + table = utils.find_table( + values, + "A1", + utils.TableDirection.table, + ) + + for rowindex, row in enumerate(table): + self.assertListEqual(row, expected_table[rowindex]) + + def test_find_table_empty_first_cell(self): + """Test find table with first cell empty""" + values = [ + ["", "B1", "C1", ""], + ["A2", "B2", "C2", ""], + ["A3", "B3", "C3", ""], + ["", "", "", ""], + ] + expected_table = [ + ["", "B1", "C1"], + ["A2", "B2", "C2"], + ["A3", "B3", "C3"], + ] + + table = utils.find_table( + values, + "A1", + utils.TableDirection.table, + ) + + for rowindex, row in enumerate(table): + self.assertListEqual(row, expected_table[rowindex]) + + def test_find_table_first_column_gap(self): + """Test find table with a gap in first column""" + values = [ + ["A1", "B1", "C1", ""], + ["", "B2", "C2", ""], + ["A3", "B3", "C3", ""], + ["", "", "", ""], + ] + expected_table = [ + ["A1", "B1", "C1"], + ["", "B2", "C2"], + ["A3", "B3", "C3"], + ] + + table = utils.find_table( + values, + "A1", + utils.TableDirection.table, + ) + + for rowindex, row in enumerate(table): + self.assertListEqual(row, expected_table[rowindex]) + + def test_find_table_last_column_gap(self): + """Test find table with a gap in last column""" + values = [ + ["A1", "B1", "C1", ""], + ["A2", "B2", "", ""], + ["A3", "B3", "C3", ""], + ["", "", "", ""], + ] + expected_table = [ + ["A1", "B1", "C1"], + ] + + table = utils.find_table( + values, + "A1", + utils.TableDirection.table, + ) + + for rowindex, row in enumerate(table): + self.assertListEqual(row, expected_table[rowindex]) From 709ff577fea9db06c0e6aa5252412b48dbba74f4 Mon Sep 17 00:00:00 2001 From: alifeee Date: Tue, 9 Jul 2024 01:26:48 +0100 Subject: [PATCH 05/11] update tests to reflect expected behaviour (failing) --- tests/utils_test.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/utils_test.py b/tests/utils_test.py index 6bd10fa2d..19ab245ae 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -508,9 +508,9 @@ def test_find_table_simple(self): """Test find table with basic case""" values = [ ["A1", "B1", "C1", "D1"], - ["", "B2", "C2", "", "D2"], - ["", "B3", "C3", "", "D3"], - ["", "", "", "", "D4"], + ["", "B2", "C2", "", "E2"], + ["", "B3", "C3", "D3", "E3"], + ["A4", "", "C4", "D4", "E4"], ] table = utils.find_table( @@ -610,8 +610,6 @@ def test_find_table_first_column_gap(self): ] expected_table = [ ["A1", "B1", "C1"], - ["", "B2", "C2"], - ["A3", "B3", "C3"], ] table = utils.find_table( @@ -633,6 +631,8 @@ def test_find_table_last_column_gap(self): ] expected_table = [ ["A1", "B1", "C1"], + ["A2", "B2", ""], + ["A3", "B3", "C3"], ] table = utils.find_table( From 67bf87f94814aab1501171e470e697afc42a7f55 Mon Sep 17 00:00:00 2001 From: alifeee Date: Tue, 9 Jul 2024 01:27:01 +0100 Subject: [PATCH 06/11] update `find_table` to return correct behaviour --- gspread/utils.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/gspread/utils.py b/gspread/utils.py index 74c5af007..e4e060261 100644 --- a/gspread/utils.py +++ b/gspread/utils.py @@ -1093,12 +1093,7 @@ def find_table( if direction == TableDirection.table: rightMost = _expand_right(values, col, len(values[row]), row) - - checkColumn = rightMost - if checkColumn != 0: - checkColumn -= 1 - - bottomMost = _expand_bottom(values, row, len(values), checkColumn) + bottomMost = _expand_bottom(values, row, len(values), col) result = [] From 24545b9aaf5ab4bd5f0e6ef80bab47056bd4fe22 Mon Sep 17 00:00:00 2001 From: Alexandre Lavigne Date: Wed, 10 Jul 2024 00:02:10 +0200 Subject: [PATCH 07/11] fixup! update `find_table` to return correct behaviour --- gspread/utils.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/gspread/utils.py b/gspread/utils.py index e4e060261..1443bead0 100644 --- a/gspread/utils.py +++ b/gspread/utils.py @@ -995,15 +995,10 @@ def _expand_right(values: List[List[str]], start: int, end: int, row: int) -> in If no empty value is found, it will return the given ``end`` index. """ - for column in range(start, end): - # in case the given row is smaller that what is being asked - if column >= len(values[row]): - return len(values[row]) - 1 - - if values[row][column] == "": - return column - - return end + try: + return values[row].index('""', start, end) + except ValueError: + return end def _expand_bottom(values: List[List[str]], start: int, end: int, col: int) -> int: @@ -1021,11 +1016,8 @@ def _expand_bottom(values: List[List[str]], start: int, end: int, col: int) -> i if rows >= len(values): return len(values) - 1 - # this row is smaller than the others, just keep looking - if col >= len(values[rows]): - continue - - if values[rows][col] == "": + # check if cell is empty (or the row => empty cell) + if col >= len(values[rows]) or values[rows][col] == "": return rows return end @@ -1092,8 +1084,8 @@ def find_table( bottomMost = row + 1 if direction == TableDirection.table: - rightMost = _expand_right(values, col, len(values[row]), row) bottomMost = _expand_bottom(values, row, len(values), col) + rightMost = _expand_right(values, col, len(values[bottomMost]), bottomMost) result = [] From c5fa638d09f14906b7ad0d98b7bd78cd252e427f Mon Sep 17 00:00:00 2001 From: Alexandre Lavigne Date: Wed, 31 Jul 2024 20:14:50 +0200 Subject: [PATCH 08/11] fixup! update `find_table` to return correct behaviour --- gspread/utils.py | 27 +++++++++++++-------------- tests/utils_test.py | 10 +++++----- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/gspread/utils.py b/gspread/utils.py index 1443bead0..5e389ea58 100644 --- a/gspread/utils.py +++ b/gspread/utils.py @@ -986,30 +986,26 @@ def to_records( def _expand_right(values: List[List[str]], start: int, end: int, row: int) -> int: - """This is a private function, returning the column index of the first empty cell + """This is a private function, returning the column index of the last non empty cell on the given row. Search starts from ``start`` index column. Search ends on ``end`` index column. Searches only in the row pointed by ``row``. - - If no empty value is found, it will return the given ``end`` index. """ try: - return values[row].index('""', start, end) + return values[row].index("", start, end) - 1 except ValueError: return end def _expand_bottom(values: List[List[str]], start: int, end: int, col: int) -> int: - """This is a private function, returning the row index of the first empty cell + """This is a private function, returning the row index of the last non empty cell on the given column. Search starts from ``start`` index row. Search ends on ``end`` index row. Searches only in the column pointed by ``col``. - - If no empty value is found, it will return the given ``end`` index. """ for rows in range(start, end): # in case we try to look further than last row @@ -1018,9 +1014,9 @@ def _expand_bottom(values: List[List[str]], start: int, end: int, col: int) -> i # check if cell is empty (or the row => empty cell) if col >= len(values[rows]) or values[rows][col] == "": - return rows + return rows - 1 - return end + return end - 1 def find_table( @@ -1076,12 +1072,15 @@ def find_table( col -= 1 if direction == TableDirection.down: - rightMost = col + 1 + rightMost = col bottomMost = _expand_bottom(values, row, len(values), col) if direction == TableDirection.right: - rightMost = _expand_right(values, col, len(values[row]), row) - bottomMost = row + 1 + if row >= len(values): + rightMost = len(values) - 1 + else: + rightMost = _expand_right(values, col, len(values[row]), row) + bottomMost = row if direction == TableDirection.table: bottomMost = _expand_bottom(values, row, len(values), col) @@ -1090,8 +1089,8 @@ def find_table( result = [] # build resulting array - for rows in values[row:bottomMost]: - result.append(rows[col:rightMost]) + for rows in values[row : bottomMost + 1]: + result.append(rows[col : rightMost + 1]) return result diff --git a/tests/utils_test.py b/tests/utils_test.py index 19ab245ae..f986d9bd5 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -532,8 +532,8 @@ def test_find_table_simple(self): no_values = utils.find_table(values, "A2", utils.TableDirection.table) table_values = [ - ["B2", "C2"], - ["B3", "C3"], + ["B2", "C2", "", "E2"], + ["B3", "C3", "D3", "E3"], ] for rowindex, row in enumerate(table): self.assertListEqual(row, table_values[rowindex]) @@ -563,9 +563,9 @@ def test_find_table_header_gap(self): ["", "", "", ""], ] expected_table = [ - ["A1"], - ["A2"], - ["A3"], + ["A1", "", "C1"], + ["A2", "B2", "C2"], + ["A3", "B3", "C3"], ] table = utils.find_table( From d62efb0be894f5e319d453aa3022706b5226b543 Mon Sep 17 00:00:00 2001 From: Alexandre Lavigne Date: Mon, 19 Aug 2024 21:15:23 +0200 Subject: [PATCH 09/11] fixup! update `find_table` to return correct behaviour --- gspread/utils.py | 31 ++++++++++++++++------- gspread/worksheet.py | 26 ++++++++++---------- tests/utils_test.py | 58 +++++++++++++++++++++++--------------------- 3 files changed, 65 insertions(+), 50 deletions(-) diff --git a/gspread/utils.py b/gspread/utils.py index 5e389ea58..ab2cb66eb 100644 --- a/gspread/utils.py +++ b/gspread/utils.py @@ -1030,10 +1030,12 @@ def find_table( * ``TableDirection.right``: expands right until the first empty cell * ``TableDirection.down``: expands down until the first empty cell - * ``TableDirection.table``: expands right until the first empty cell, then down until the first empty cell + * ``TableDirection.table``: expands right until the first empty cell and down until first empty cell - Regardless of the direction this function always returns a matrix of data, even if it has - only one column. + In case of empty result an empty list is restuned. + + When the given ``start_range`` is outside the given matrix of values the exception + `~gspread.exceptions.InvalidInputValue` is raised. Example:: @@ -1052,7 +1054,7 @@ def find_table( .. note:: - the ``TableDirection.table`` will first look right, then look down. + the ``TableDirection.table`` will look right from starting cell then look down from starting cell. It will not check cells located inside the table. This could lead to potential empty values located in the middle of the table. @@ -1071,20 +1073,31 @@ def find_table( row -= 1 col -= 1 + if row >= len(values): + raise InvalidInputValue( + "given row for start_range is outside given values: start range row ({}) >= rows in values {}".format( + row, len(values) + ) + ) + + if col >= len(values[row]): + raise InvalidInputValue( + "given collumn for start_range is outside given values: start range column ({}) >= columns in values {}".format( + col, len(values[row]) + ) + ) + if direction == TableDirection.down: rightMost = col bottomMost = _expand_bottom(values, row, len(values), col) if direction == TableDirection.right: - if row >= len(values): - rightMost = len(values) - 1 - else: - rightMost = _expand_right(values, col, len(values[row]), row) bottomMost = row + rightMost = _expand_right(values, col, len(values[row]), row) if direction == TableDirection.table: + rightMost = _expand_right(values, col, len(values[row]), row) bottomMost = _expand_bottom(values, row, len(values), col) - rightMost = _expand_right(values, col, len(values[bottomMost]), bottomMost) result = [] diff --git a/gspread/worksheet.py b/gspread/worksheet.py index 17f7c60b8..2e05b41a9 100644 --- a/gspread/worksheet.py +++ b/gspread/worksheet.py @@ -3350,31 +3350,31 @@ def expand( * ``TableDirection.right``: expands right until the first empty cell * ``TableDirection.down``: expands down until the first empty cell - * ``TableDirection.table``: expands right until the first empty cell, then down until the first empty cell + * ``TableDirection.table``: expands right until the first empty cell and down until the first empty cell - Regardless of the direction this function always returns a matrix of data, even if it has - only one column. + In case of empty result an empty list is restuned. + + When the given ``start_range`` is outside the given matrix of values the exception + `~gspread.exceptions.InvalidInputValue` is raised. Example:: values = [ - ['', '', '', '' , '' , ''], - ['', 'B2', 'C2', 'D2', '' , 'F2'], - ['', 'B3', '' , 'D3', '' , 'F3'], - ['', 'B4', 'C4', 'D4', '' , 'F4'], - ['', '' , '' , '' , '' , 'F5'], + ['', '', '', '', '' ], + ['', 'B2', 'C2', '', 'E2'], + ['', 'B3', 'C3', '', 'E3'], + ['', '' , '' , '', 'E4'], ] - >>> worksheet.expand_table(TableDirection.table, 'B2') + >>> utils.find_table(TableDirection.table, 'B2') [ - ['B2', 'C2', 'D2], - ['B3', '' , 'D3'], - ['B4', 'C4', 'D4'], + ['B2', 'C2'], + ['B3', 'C3'], ] .. note:: - the ``TableDirection.table`` will first look right, then look down. + the ``TableDirection.table`` will look right from starting cell then look down from starting cell. It will not check cells located inside the table. This could lead to potential empty values located in the middle of the table. diff --git a/tests/utils_test.py b/tests/utils_test.py index f986d9bd5..bb217c1b7 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -528,43 +528,45 @@ def test_find_table_simple(self): "B2", utils.TableDirection.down, ) - single = utils.find_table(values, "C3", utils.TableDirection.table) + single = utils.find_table(values, "D1", utils.TableDirection.table) no_values = utils.find_table(values, "A2", utils.TableDirection.table) table_values = [ - ["B2", "C2", "", "E2"], - ["B3", "C3", "D3", "E3"], + ["B2", "C2"], + ["B3", "C3"], ] - for rowindex, row in enumerate(table): - self.assertListEqual(row, table_values[rowindex]) + for rowindex, row in enumerate(table_values): + self.assertListEqual(row, table[rowindex]) right_values = [ ["B2", "C2"], ] - for rowindex, row in enumerate(right): - self.assertListEqual(row, right_values[rowindex]) + for rowindex, row in enumerate(right_values): + self.assertListEqual(row, right[rowindex]) bottom_values = [ ["B2"], ["B3"], ] - for rowindex, row in enumerate(down): - self.assertListEqual(row, bottom_values[rowindex]) + for rowindex, row in enumerate(bottom_values): + self.assertListEqual(row, down[rowindex]) - self.assertEqual(single[0][0], "C3") + self.assertEqual(len(single), 1) + self.assertEqual(len(single[0]), 1) + self.assertEqual(single[0][0], "D1") self.assertEqual(no_values, []) - def test_find_table_header_gap(self): + def test_find_table_inner_gap(self): """Test find table with gap in header""" values = [ - ["A1", "", "C1", ""], - ["A2", "B2", "C2", ""], + ["A1", "B1", "C1", ""], + ["A2", "", "C2", ""], ["A3", "B3", "C3", ""], ["", "", "", ""], ] expected_table = [ - ["A1", "", "C1"], - ["A2", "B2", "C2"], + ["A1", "B1", "C1"], + ["A2", "", "C2"], ["A3", "B3", "C3"], ] @@ -574,21 +576,21 @@ def test_find_table_header_gap(self): utils.TableDirection.table, ) - for rowindex, row in enumerate(table): - self.assertListEqual(row, expected_table[rowindex]) + for rowindex, row in enumerate(expected_table): + self.assertListEqual(row, table[rowindex]) - def test_find_table_empty_first_cell(self): + def test_find_table_first_row_gap(self): """Test find table with first cell empty""" values = [ - ["", "B1", "C1", ""], + ["A1", "", "C1", ""], ["A2", "B2", "C2", ""], ["A3", "B3", "C3", ""], ["", "", "", ""], ] expected_table = [ - ["", "B1", "C1"], - ["A2", "B2", "C2"], - ["A3", "B3", "C3"], + ["A1"], + ["A2"], + ["A3"], ] table = utils.find_table( @@ -597,8 +599,8 @@ def test_find_table_empty_first_cell(self): utils.TableDirection.table, ) - for rowindex, row in enumerate(table): - self.assertListEqual(row, expected_table[rowindex]) + for rowindex, row in enumerate(expected_table): + self.assertListEqual(row, table[rowindex]) def test_find_table_first_column_gap(self): """Test find table with a gap in first column""" @@ -618,8 +620,8 @@ def test_find_table_first_column_gap(self): utils.TableDirection.table, ) - for rowindex, row in enumerate(table): - self.assertListEqual(row, expected_table[rowindex]) + for rowindex, row in enumerate(expected_table): + self.assertListEqual(row, table[rowindex]) def test_find_table_last_column_gap(self): """Test find table with a gap in last column""" @@ -641,5 +643,5 @@ def test_find_table_last_column_gap(self): utils.TableDirection.table, ) - for rowindex, row in enumerate(table): - self.assertListEqual(row, expected_table[rowindex]) + for rowindex, row in enumerate(expected_table): + self.assertListEqual(row, table[rowindex]) From e9fe38935b1f6ec36e967a504058f3eef5327583 Mon Sep 17 00:00:00 2001 From: Alexandre Lavigne Date: Mon, 19 Aug 2024 21:18:08 +0200 Subject: [PATCH 10/11] fixup! fixup! update `find_table` to return correct behaviour --- gspread/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gspread/utils.py b/gspread/utils.py index ab2cb66eb..9d461a0c9 100644 --- a/gspread/utils.py +++ b/gspread/utils.py @@ -1082,7 +1082,7 @@ def find_table( if col >= len(values[row]): raise InvalidInputValue( - "given collumn for start_range is outside given values: start range column ({}) >= columns in values {}".format( + "given column for start_range is outside given values: start range column ({}) >= columns in values {}".format( col, len(values[row]) ) ) From 5b41d73c32d695e6494c051889280ff37e4b1563 Mon Sep 17 00:00:00 2001 From: Alexandre Lavigne Date: Sun, 8 Sep 2024 18:17:59 +0200 Subject: [PATCH 11/11] fixup! fixup! update `find_table` to return correct behaviour --- tests/utils_test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/utils_test.py b/tests/utils_test.py index bb217c1b7..f64b759ae 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -518,6 +518,12 @@ def test_find_table_simple(self): "B2", utils.TableDirection.table, ) + + table_max_row_max_column = utils.find_table( + values, + "D3", + utils.TableDirection.table, + ) right = utils.find_table( values, "B2", @@ -535,9 +541,18 @@ def test_find_table_simple(self): ["B2", "C2"], ["B3", "C3"], ] + for rowindex, row in enumerate(table_values): self.assertListEqual(row, table[rowindex]) + table_max_row_max_column_values = [ + ["D3", "E3"], + ["D4", "E4"], + ] + + for rowindex, row in enumerate(table_max_row_max_column): + self.assertListEqual(row, table_max_row_max_column_values[rowindex]) + right_values = [ ["B2", "C2"], ] @@ -645,3 +660,16 @@ def test_find_table_last_column_gap(self): for rowindex, row in enumerate(expected_table): self.assertListEqual(row, table[rowindex]) + + def test_find_table_empty_top_left_corner(self): + """Test find table with an empty top left cell and empty adjacent cells""" + + values = [ + ["", "", "C1", ""], + ["", "B2", "C2", ""], + ["", "B3", "C3", ""], + ] + + table = utils.find_table(values, "A1", utils.TableDirection.table) + + self.assertListEqual(table, [], "resulting table should be empty")