Skip to content

Commit 5a7e7e3

Browse files
authored
Merge pull request #1180 from luissantosHCIT/append_row_to_worksheet
Added append and update row/cell methods to the Worksheet level.
2 parents 33526b3 + 48e9833 commit 5a7e7e3

File tree

5 files changed

+123
-1
lines changed

5 files changed

+123
-1
lines changed

O365/drive.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,7 @@ def _base_get_list(self, url, limit=None, *, query=None, order_by=None,
16061606
items = (
16071607
self._classifier(item)(parent=self, **{self._cloud_data_key: item})
16081608
for item in data.get('value', []))
1609+
16091610
next_link = data.get(NEXT_LINK_KEYWORD, None)
16101611
if batch and next_link:
16111612
return Pagination(parent=self, data=items,

O365/excel.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from urllib.parse import quote
1111

1212
from .drive import File
13-
from .utils import ApiComponent, TrackerSet, to_snake_case
13+
from .utils import ApiComponent, TrackerSet, to_snake_case, col_index_to_label
1414

1515
log = logging.getLogger(__name__)
1616

@@ -1948,6 +1948,74 @@ def add_named_range(self, name, reference, comment="", is_formula=False):
19481948
parent=self, **{self._cloud_data_key: response.json()}
19491949
)
19501950

1951+
def update_cells(self, address, rows):
1952+
"""
1953+
Updates the cells at a given range in this worksheet. This is a convenience method since there is no
1954+
direct endpoint API for tableless row updates.
1955+
:param str|Range address: the address to resolve to a range which can be used for updating cells.
1956+
:param list[list[str]] rows: list of rows to push to this range. If updating a single cell, pass a list
1957+
containing a single row (list) containing a single cell worth of data.
1958+
"""
1959+
if isinstance(address, str):
1960+
address = self.get_range(address)
1961+
1962+
if not isinstance(address, Range):
1963+
raise ValueError("address was not an accepted type: str or Range")
1964+
1965+
if not isinstance(rows, list):
1966+
raise ValueError("rows was not an accepted type: list[list[str]]")
1967+
1968+
# Let's not even try pushing to API if the range rectangle mismatches the input row and column count.
1969+
row_count = len(rows)
1970+
col_count = len(rows[0]) if row_count > 0 else 1
1971+
1972+
if address.row_count != row_count or address.column_count != col_count:
1973+
raise ValueError("rows and columns are not the same size as the range selected. This is required by the Microsoft Graph API.")
1974+
1975+
address.values = rows
1976+
address.update()
1977+
1978+
def append_rows(self, rows):
1979+
"""
1980+
Appends rows to the end of a worksheet. There is no direct Graph API to do this operation without a Table
1981+
instance. Instead, this method identifies the last row in the worksheet and requests a range after that row
1982+
and updates that range.
1983+
1984+
Beware! If you open your workbook from sharepoint and delete all of the rows in one go and attempt to append
1985+
new rows, you will get undefined behavior from the Microsoft Graph API. I don't know if I did not give enough
1986+
time for the backend to synchronize from the moment of deletion on my browser and the moment I triggered my
1987+
script, but this is something I have observed. Sometimes insertion fails and sometimes it inserts where the new
1988+
row would have been if data had not been deleted from the browser side. Maybe it is an API cache issue. However,
1989+
after the first row is inserted successfully, this undefined behavior goes away on repeat calls to my scripts.
1990+
Documenting this behavior for future consumers of this API.
1991+
1992+
:param list[list[str]] rows: list of rows to push to this range. If updating a single cell, pass a list
1993+
containing a single row (list) containing a single cell worth of data.
1994+
"""
1995+
row_count = len(rows)
1996+
col_count = len(rows[0]) if row_count > 0 else 0
1997+
col_index = col_count - 1
1998+
1999+
# Find the last row index so we can grab a range after it.
2000+
current_range = self.get_used_range()
2001+
# Minor adjustment because Graph will return [['']] in an empty worksheet.
2002+
# Also, beware that Graph might report ghost values if testing using the front end site and that can be interesting
2003+
# during debugging. I ctrl + A and delete then click elsewhere before testing again.
2004+
# Might also take a moment for the backend to eventually catch up to the changes.
2005+
# Graph can be weirdly slow. It might be an institution thing.
2006+
if current_range.row_count == 1 and len(current_range.values[0]) == 1 and current_range.values[0][0] == '':
2007+
current_range.values = []
2008+
current_range.row_count = 0
2009+
2010+
target_index = current_range.row_count
2011+
2012+
# Generate the address needed to outline the bounding rectangle to use to fill in data.
2013+
col_name = col_index_to_label(col_index)
2014+
insert_range_address = 'A{}:{}{}'.format(target_index + 1, col_name, target_index + row_count)
2015+
2016+
# Request to push the data to the given range.
2017+
self.update_cells(insert_range_address, rows)
2018+
19512019
def get_named_range(self, name):
19522020
"""Retrieves a Named range by it's name"""
19532021
url = self.build_url(self._endpoints.get("get_named_range").format(name=name))

O365/utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .utils import NEXT_LINK_KEYWORD, ME_RESOURCE, USERS_RESOURCE
66
from .utils import OneDriveWellKnowFolderNames, Pagination, Query
77
from .token import BaseTokenBackend, FileSystemTokenBackend, FirestoreBackend, AWSS3Backend, AWSSecretsBackend, EnvTokenBackend, BitwardenSecretsManagerBackend, DjangoTokenBackend
8+
from .range import col_index_to_label
89
from .windows_tz import get_iana_tz, get_windows_tz
910
from .consent import consent_input_token
1011
from .casing import to_snake_case, to_pascal_case, to_camel_case

O365/utils/range.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
CAPITALIZED_ASCII_CODE = ord('A')
2+
CAPITALIZED_WINDOW = 26
3+
4+
5+
def col_index_to_label(col_index):
6+
"""
7+
Given a column index, returns the label corresponding to the column name. For example, index 0 would be
8+
A ... until 25 which would be Z.
9+
This function will recurse until a full label is generated using chunks of CAPITALIZED_WINDOW. Meaning,
10+
an index of 51 should yield a label of ZZ corresponding to the ZZ column.
11+
12+
:param int col_index: number associated with the index position of the requested column. For example, column index 0
13+
would correspond to column label A.
14+
"""
15+
label = ''
16+
extra_letter_index = (col_index // CAPITALIZED_WINDOW) - 1 # Minor adjustment for the no repeat (0) case.
17+
18+
# If we do need to prepend a new letter to the column label do so recursively such that we could simulate
19+
# labels like AA or AAA or AAAA ... etc.
20+
if extra_letter_index >= 0:
21+
label += col_index_to_label(extra_letter_index)
22+
23+
# Otherwise, passthrough and add the letter the input index corresponds to.
24+
return label + index_to_col_char(col_index)
25+
26+
def index_to_col_char(index):
27+
return chr(CAPITALIZED_ASCII_CODE + index % CAPITALIZED_WINDOW)

tests/test_range.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import pytest
2+
3+
from O365.utils import col_index_to_label
4+
5+
6+
class TestRange:
7+
EXPECTED_CHARS = [
8+
'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',
9+
'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',
10+
'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',
11+
]
12+
def setup_class(self):
13+
pass
14+
15+
def teardown_class(self):
16+
pass
17+
18+
def test_col_index_to_label(self):
19+
for i in range(len(self.EXPECTED_CHARS)):
20+
expected_index = i
21+
expected_label = self.EXPECTED_CHARS[expected_index]
22+
label = col_index_to_label(i)
23+
print(f'Index {i} Letter Index {i} Label {label} Expected {expected_label}')
24+
25+
assert label == expected_label

0 commit comments

Comments
 (0)