Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions O365/drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 69 additions & 1 deletion O365/excel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions O365/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions O365/utils/range.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions tests/test_range.py
Original file line number Diff line number Diff line change
@@ -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