diff --git a/parsons/google/google_sheets.py b/parsons/google/google_sheets.py index 67deaefc1c..1ab1b2dd5b 100644 --- a/parsons/google/google_sheets.py +++ b/parsons/google/google_sheets.py @@ -3,7 +3,7 @@ import logging from parsons.etl.table import Table -from parsons.google.utitities import setup_google_application_credentials +from parsons.google.utitities import setup_google_application_credentials, hexavigesimal import gspread from google.oauth2.service_account import Credentials @@ -293,6 +293,63 @@ def append_to_sheet( sheet.update_cells(cells, value_input_option=value_input_option) logger.info(f"Appended {table.num_rows} rows to worksheet.") + def paste_data_in_sheet( + self, spreadsheet_id, table, worksheet=0, header=True, startrow=0, startcol=0 + ): + """ + Pastes data from a Parsons table to a Google sheet. Note that this may overwrite + presently existing data. This function is useful for adding data to a subsection + if an existint sheet that will have other existing data - constrast to + `overwrite_sheet` (which will fully replace any existing data) and `append_to_sheet` + (whuch sticks the data only after all other existing data). + + `Args:` + spreadsheet_id: str + The ID of the spreadsheet (Tip: Get this from the spreadsheet URL). + table: obj + Parsons table + worksheet: str or int + The index or the title of the worksheet. The index begins with 0. + header: bool + Whether or not the header row gets pasted with the data. + startrow: int + Starting row position of pasted data. Counts from 0. + startcol: int + Starting column position of pasted data. Counts from 0. + """ + sheet = self._get_worksheet(spreadsheet_id, worksheet) + + number_of_columns = len(table.columns) + number_of_rows = table.num_rows + 1 if header else table.num_rows + + if not number_of_rows or not number_of_columns: # No data to paste + logger.warning( + f"No data available to paste, table size " + f"({number_of_rows}, {number_of_columns}). Skipping." + ) + return + + # gspread uses ranges like "C3:J7", so we need to convert to this format + data_range = ( + hexavigesimal(startcol + 1) + + str(startrow + 1) + + ":" + + hexavigesimal(startcol + number_of_columns) + + str(startrow + number_of_rows) + ) + + # Unpack data. Hopefully this is small enough for memory + data = [[]] * table.num_rows + for row_num, row in enumerate(table.data): + data[row_num] = list(row) + + if header: + sheet.update(data_range, [table.columns] + data) + else: + sheet.update(data_range, data) + + logger.info(f"Pasted data to {data_range} in worksheet.") + def overwrite_sheet( self, spreadsheet_id, table, worksheet=0, user_entered_value=False, **kwargs ): diff --git a/parsons/google/utitities.py b/parsons/google/utitities.py index abda0bf212..75cdf01de0 100644 --- a/parsons/google/utitities.py +++ b/parsons/google/utitities.py @@ -22,3 +22,29 @@ def setup_google_application_credentials( creds_path = credentials os.environ[env_var_name] = creds_path + + +def hexavigesimal(n: int) -> str: + """ + Converts an integer value to the type of strings you see on spreadsheets + (A, B,...,Z, AA, AB, ...). + + Code based on + https://stackoverflow.com/questions/16190452/converting-from-number-to-hexavigesimal-letters + + `Args:` + n: int + A positive valued integer. + + `Returns:` + str + The hexavigeseimal representation of n + """ + if n < 1: + raise ValueError(f"This function only works for positive integers. Provided value {n}.") + + chars = "" + while n != 0: + chars = chr((n - 1) % 26 + 65) + chars # 65 makes us start at A + n = (n - 1) // 26 + return chars diff --git a/test/test_google/test_google_sheets.py b/test/test_google/test_google_sheets.py index d7672b76a7..ed6811bb6a 100644 --- a/test/test_google/test_google_sheets.py +++ b/test/test_google/test_google_sheets.py @@ -123,6 +123,82 @@ def test_append_user_entered_to_spreadsheet(self): self.assertEqual(formula_vals[0], "27") self.assertEqual(formula_vals[1], "Budapest") + def test_paste_data_in_sheet(self): + # Testing if we can paste data to a spreadsheet + # TODO: there's probably a smarter way to test this code + self.google_sheets.add_sheet(self.spreadsheet_id, "PasteDataSheet") + + paste_table1 = Table( + [ + {"col1": 1, "col2": 2}, + {"col1": 5, "col2": 6}, + ] + ) + paste_table2 = Table( + [ + {"col3": 3, "col4": 4}, + {"col3": 7, "col4": 8}, + ] + ) + paste_table3 = Table( + [ + {"col1": 9, "col2": 10}, + {"col1": 13, "col2": 14}, + ] + ) + paste_table4 = Table( + [ + {"col3": 11, "col4": 12}, + {"col3": 15, "col4": 16}, + ] + ) + + # When we read the spreadsheet, it assumes data is all strings + expected_table = Table( + [ + {"col1": "1", "col2": "2", "col3": "3", "col4": "4"}, + {"col1": "5", "col2": "6", "col3": "7", "col4": "8"}, + {"col1": "9", "col2": "10", "col3": "11", "col4": "12"}, + {"col1": "13", "col2": "14", "col3": "15", "col4": "16"}, + ] + ) + + self.google_sheets.paste_data_in_sheet( + self.spreadsheet_id, + paste_table1, + worksheet="PasteDataSheet", + header=True, + startrow=0, + startcol=0, + ) + self.google_sheets.paste_data_in_sheet( + self.spreadsheet_id, + paste_table2, + worksheet="PasteDataSheet", + header=True, + startrow=0, + startcol=2, + ) + self.google_sheets.paste_data_in_sheet( + self.spreadsheet_id, + paste_table3, + worksheet="PasteDataSheet", + header=False, + startrow=3, + startcol=0, + ) + self.google_sheets.paste_data_in_sheet( + self.spreadsheet_id, + paste_table4, + worksheet="PasteDataSheet", + header=False, + startrow=3, + startcol=2, + ) + + result_table = self.google_sheets.get_worksheet(self.spreadsheet_id, "PasteDataSheet") + self.assertEqual(result_table.to_dicts(), expected_table.to_dicts()) + def test_overwrite_spreadsheet(self): new_table = Table( [ diff --git a/test/test_google/test_utilities.py b/test/test_google/test_utilities.py index 5a6b8c2966..accb506f90 100644 --- a/test/test_google/test_utilities.py +++ b/test/test_google/test_utilities.py @@ -72,3 +72,16 @@ def test_credentials_are_valid_after_double_call(self): actual = fsnd.read() self.assertEqual(self.cred_contents, json.loads(actual)) self.assertEqual(ffst.read(), actual) + + +class TestHexavigesimal(unittest.TestCase): + + def test_returns_A_on_1(self): + self.assertEqual(util.hexavigesimal(1), "A") + + def test_returns_AA_on_27(self): + self.assertEqual(util.hexavigesimal(27), "AA") + + def test_returns_error_on_0(self): + with self.assertRaises(ValueError): + util.hexavigesimal(0)