diff --git a/O365/drive.py b/O365/drive.py index 97ea1f29..b1f35086 100644 --- a/O365/drive.py +++ b/O365/drive.py @@ -1606,6 +1606,7 @@ def _base_get_list(self, url, limit=None, *, query=None, order_by=None, items = ( self._classifier(item)(parent=self, **{self._cloud_data_key: item}) for item in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) if batch and next_link: return Pagination(parent=self, data=items, diff --git a/O365/excel.py b/O365/excel.py index 13c715fc..eea0236a 100644 --- a/O365/excel.py +++ b/O365/excel.py @@ -10,7 +10,7 @@ from urllib.parse import quote from .drive import File -from .utils import ApiComponent, TrackerSet, to_snake_case +from .utils import ApiComponent, TrackerSet, to_snake_case, col_index_to_label log = logging.getLogger(__name__) @@ -1948,6 +1948,74 @@ def add_named_range(self, name, reference, comment="", is_formula=False): parent=self, **{self._cloud_data_key: response.json()} ) + def update_cells(self, address, rows): + """ + Updates the cells at a given range in this worksheet. This is a convenience method since there is no + direct endpoint API for tableless row updates. + :param str|Range address: the address to resolve to a range which can be used for updating cells. + :param list[list[str]] rows: list of rows to push to this range. If updating a single cell, pass a list + containing a single row (list) containing a single cell worth of data. + """ + if isinstance(address, str): + address = self.get_range(address) + + if not isinstance(address, Range): + raise ValueError("address was not an accepted type: str or Range") + + if not isinstance(rows, list): + raise ValueError("rows was not an accepted type: list[list[str]]") + + # Let's not even try pushing to API if the range rectangle mismatches the input row and column count. + row_count = len(rows) + col_count = len(rows[0]) if row_count > 0 else 1 + + if address.row_count != row_count or address.column_count != col_count: + raise ValueError("rows and columns are not the same size as the range selected. This is required by the Microsoft Graph API.") + + address.values = rows + address.update() + + def append_rows(self, rows): + """ + Appends rows to the end of a worksheet. There is no direct Graph API to do this operation without a Table + instance. Instead, this method identifies the last row in the worksheet and requests a range after that row + and updates that range. + + Beware! If you open your workbook from sharepoint and delete all of the rows in one go and attempt to append + new rows, you will get undefined behavior from the Microsoft Graph API. I don't know if I did not give enough + time for the backend to synchronize from the moment of deletion on my browser and the moment I triggered my + script, but this is something I have observed. Sometimes insertion fails and sometimes it inserts where the new + row would have been if data had not been deleted from the browser side. Maybe it is an API cache issue. However, + after the first row is inserted successfully, this undefined behavior goes away on repeat calls to my scripts. + Documenting this behavior for future consumers of this API. + + :param list[list[str]] rows: list of rows to push to this range. If updating a single cell, pass a list + containing a single row (list) containing a single cell worth of data. + """ + row_count = len(rows) + col_count = len(rows[0]) if row_count > 0 else 0 + col_index = col_count - 1 + + # Find the last row index so we can grab a range after it. + current_range = self.get_used_range() + # Minor adjustment because Graph will return [['']] in an empty worksheet. + # Also, beware that Graph might report ghost values if testing using the front end site and that can be interesting + # during debugging. I ctrl + A and delete then click elsewhere before testing again. + # Might also take a moment for the backend to eventually catch up to the changes. + # Graph can be weirdly slow. It might be an institution thing. + if current_range.row_count == 1 and len(current_range.values[0]) == 1 and current_range.values[0][0] == '': + current_range.values = [] + current_range.row_count = 0 + + target_index = current_range.row_count + + # Generate the address needed to outline the bounding rectangle to use to fill in data. + col_name = col_index_to_label(col_index) + insert_range_address = 'A{}:{}{}'.format(target_index + 1, col_name, target_index + row_count) + + # Request to push the data to the given range. + self.update_cells(insert_range_address, rows) + def get_named_range(self, name): """Retrieves a Named range by it's name""" url = self.build_url(self._endpoints.get("get_named_range").format(name=name)) diff --git a/O365/utils/__init__.py b/O365/utils/__init__.py index 16e0ea25..a9521802 100644 --- a/O365/utils/__init__.py +++ b/O365/utils/__init__.py @@ -5,6 +5,7 @@ from .utils import NEXT_LINK_KEYWORD, ME_RESOURCE, USERS_RESOURCE from .utils import OneDriveWellKnowFolderNames, Pagination, Query from .token import BaseTokenBackend, FileSystemTokenBackend, FirestoreBackend, AWSS3Backend, AWSSecretsBackend, EnvTokenBackend, BitwardenSecretsManagerBackend, DjangoTokenBackend +from .range import col_index_to_label from .windows_tz import get_iana_tz, get_windows_tz from .consent import consent_input_token from .casing import to_snake_case, to_pascal_case, to_camel_case diff --git a/O365/utils/range.py b/O365/utils/range.py new file mode 100644 index 00000000..0e472fb3 --- /dev/null +++ b/O365/utils/range.py @@ -0,0 +1,27 @@ +CAPITALIZED_ASCII_CODE = ord('A') +CAPITALIZED_WINDOW = 26 + + +def col_index_to_label(col_index): + """ + Given a column index, returns the label corresponding to the column name. For example, index 0 would be + A ... until 25 which would be Z. + This function will recurse until a full label is generated using chunks of CAPITALIZED_WINDOW. Meaning, + an index of 51 should yield a label of ZZ corresponding to the ZZ column. + + :param int col_index: number associated with the index position of the requested column. For example, column index 0 + would correspond to column label A. + """ + label = '' + extra_letter_index = (col_index // CAPITALIZED_WINDOW) - 1 # Minor adjustment for the no repeat (0) case. + + # If we do need to prepend a new letter to the column label do so recursively such that we could simulate + # labels like AA or AAA or AAAA ... etc. + if extra_letter_index >= 0: + label += col_index_to_label(extra_letter_index) + + # Otherwise, passthrough and add the letter the input index corresponds to. + return label + index_to_col_char(col_index) + +def index_to_col_char(index): + return chr(CAPITALIZED_ASCII_CODE + index % CAPITALIZED_WINDOW) diff --git a/tests/test_range.py b/tests/test_range.py new file mode 100644 index 00000000..6fdbf373 --- /dev/null +++ b/tests/test_range.py @@ -0,0 +1,25 @@ +import pytest + +from O365.utils import col_index_to_label + + +class TestRange: + EXPECTED_CHARS = [ + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + 'AA','AB','AC','AD','AE','AF','AG','AH','AI','AJ','AK','AL','AM','AN','AO','AP','AQ','AR','AS','AT','AU','AV','AW','AX','AY','AZ', + 'BA','BB','BC','BD','BE','BF','BG','BH','BI','BJ','BK','BL','BM','BN','BO','BP','BQ','BR','BS','BT','BU','BV','BW','BX','BY','BZ', + ] + def setup_class(self): + pass + + def teardown_class(self): + pass + + def test_col_index_to_label(self): + for i in range(len(self.EXPECTED_CHARS)): + expected_index = i + expected_label = self.EXPECTED_CHARS[expected_index] + label = col_index_to_label(i) + print(f'Index {i} Letter Index {i} Label {label} Expected {expected_label}') + + assert label == expected_label