Skip to content

Commit 25a734a

Browse files
authored
Merge branch 'O365:master' into query-docs
2 parents de356fb + 18bb4a7 commit 25a734a

29 files changed

+277
-126
lines changed

CHANGES.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
Almost every release features a lot of bugfixes but those are not listed here.
44

5+
## Version 2.1.7 (2025-09-05)
6+
7+
> [!IMPORTANT]
8+
> **Breaking Change:** Removed support for Python 3.9
9+
> **Breaking Change:** Removed Old Query in favour of the new QueryBuilder (both are interchangeable for other methods to use it, but are build differently)
10+
11+
- Tasks: Add population of checklist_items on Task (Thanks @RogerSelwyn)
12+
- Excel: Added `append_rows` and `update_cells` to `WorkSheet` (Thanks @luissantosHCIT)
13+
14+
## Version 2.1.6 (2025-09-05)
15+
16+
- Version Yanked
17+
18+
## Version 2.1.5 (2025-08-04)
19+
- Bug fixing release
20+
521
## Version 2.1.4 (2025-06-03)
622
- Calendar: Schedule.get_calendar method can now use query objects with select, expand and order by (Thanks @RogerSelwyn)
723

@@ -14,7 +30,6 @@ Almost every release features a lot of bugfixes but those are not listed here.
1430
- Tasks: Added support for check list items (Thanks @RogerSelwyn)
1531
- Removed Office365 protocol
1632

17-
1833
## Version 2.1.2 (2025-04-08)
1934
- Calendar: list_calendars now allows pagination (Thanks @RogerSelwyn)
2035
- Query: added new experimental Query object that will replace the current Query object in the future. Available in utils.query.

O365/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import warnings
66
import sys
77

8-
from .__version__ import __version__
9-
108
from .account import Account
119
from .connection import Connection, Protocol, MSGraphProtocol
1210
from .utils import FileSystemTokenBackend, EnvTokenBackend

O365/__version__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

O365/account.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(self, credentials: Tuple[str, str], *,
2525
protocol = protocol or MSGraphProtocol # Defaults to Graph protocol
2626
if isinstance(protocol, type):
2727
protocol = protocol(default_resource=main_resource, **kwargs)
28-
#: The protocol to use for the account. Defaults ot MSGraphProtocol. |br| **Type:** Protocol
28+
# The protocol to use for the account. Defaults ot MSGraphProtocol. |br| **Type:** Protocol
2929
self.protocol: Protocol = protocol
3030

3131
if not isinstance(self.protocol, Protocol):

O365/connection.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -914,17 +914,22 @@ def _check_delay(self) -> None:
914914
self._previous_request_at = time.time()
915915

916916
def _internal_request(
917-
self, session_obj: Session, url: str, method: str, ignore401: bool = False, **kwargs
917+
self,
918+
session_obj: Session,
919+
url: str,
920+
method: str,
921+
ignore40x: bool = False,
922+
**kwargs,
918923
) -> Response:
919924
"""Internal handling of requests. Handles Exceptions.
920925
921926
:param session_obj: a requests Session instance.
922927
:param str url: url to send request to
923928
:param str method: type of request (get/put/post/patch/delete)
924-
:param bool ignore401: indicates whether to ignore 401 error when it would
929+
:param bool ignore40x: indicates whether to ignore 40x errors when it would
925930
indicate that there the token has expired. This is set to 'True' for the
926-
first call to the api, and 'False' for the call that is initiated after a
927-
tpken refresh.
931+
first call to the api, and 'False' for the call that is initiated after a
932+
tpken refresh.
928933
:param kwargs: extra params to send to the request api
929934
:return: Response of the request
930935
:rtype: requests.Response
@@ -983,7 +988,7 @@ def _internal_request(
983988
raise e # re-raise exception
984989
except HTTPError as e:
985990
# Server response with 4XX or 5XX error status codes
986-
if e.response.status_code == 401 and ignore401 is True:
991+
if e.response.status_code in [401, 403] and ignore40x is True:
987992
# This could be a token expired error.
988993
if self.token_backend.token_is_expired(username=self.username):
989994
# Access token has expired, try to refresh the token and try again on the next loop
@@ -1042,7 +1047,9 @@ def naive_request(self, url: str, method: str, **kwargs) -> Response:
10421047
# lazy creation of a naive session
10431048
self.naive_session = self.get_naive_session()
10441049

1045-
return self._internal_request(self.naive_session, url, method, ignore401=False, **kwargs)
1050+
return self._internal_request(
1051+
self.naive_session, url, method, ignore40x=False, **kwargs
1052+
)
10461053

10471054
def oauth_request(self, url: str, method: str, **kwargs) -> Response:
10481055
"""Makes a request to url using an oauth session.
@@ -1063,20 +1070,24 @@ def oauth_request(self, url: str, method: str, **kwargs) -> Response:
10631070
f"No auth token found. Authentication Flow needed for user {self.username}"
10641071
)
10651072

1066-
# In the event of a response that returned 401 unauthorised the ignore401 flag indicates
1067-
# that the 401 can be a token expired error. MsGraph is returning 401 when the access token
1068-
# has expired. We can not distinguish between a real 401 or token expired 401. So in the event
1069-
# of a 401 http error we will ignore the first time and try to refresh the token, and then
1070-
# re-run the request. If the 401 goes away we can move on. If it keeps the 401 then we will
1073+
# In the event of a response that returned 401 or 403 unauthorised the ignore40x flag indicates
1074+
# that the 40x can be a token expired error. MsGraph is returning 401 or 403 when the access token
1075+
# has expired. We can not distinguish between a real 40x or token expired 40x. So in the event
1076+
# of a 40x http error we will ignore the first time and try to refresh the token, and then
1077+
# re-run the request. If the 40x goes away we can move on. If it keeps the 40x then we will
10711078
# raise the error.
10721079
try:
1073-
return self._internal_request(self.session, url, method, ignore401=True, **kwargs)
1080+
return self._internal_request(
1081+
self.session, url, method, ignore40x=True, **kwargs
1082+
)
10741083
except TokenExpiredError as e:
10751084
# refresh and try again the request!
10761085

10771086
# try to refresh the token and/or follow token backend answer on 'should_refresh_token'
10781087
if self._try_refresh_token():
1079-
return self._internal_request(self.session, url, method, ignore401=False, **kwargs)
1088+
return self._internal_request(
1089+
self.session, url, method, ignore40x=False, **kwargs
1090+
)
10801091
else:
10811092
raise e
10821093

O365/drive.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
ApiComponent,
1515
OneDriveWellKnowFolderNames,
1616
Pagination,
17-
ExperimentalQuery,
17+
QueryBuilder,
1818
CompositeFilter
1919
)
2020

@@ -1188,12 +1188,12 @@ def get_child_folders(self, limit=None, *, query=None, order_by=None, batch=None
11881188
if query:
11891189
if not isinstance(query, str):
11901190
if isinstance(query, CompositeFilter):
1191-
q = ExperimentalQuery(protocol=self.protocol)
1191+
q = QueryBuilder(protocol=self.protocol)
11921192
query = query & q.unequal('folder', None)
11931193
else:
11941194
query = query.on_attribute('folder').unequal(None)
11951195
else:
1196-
q = ExperimentalQuery(protocol=self.protocol)
1196+
q = QueryBuilder(protocol=self.protocol)
11971197
query = q.unequal('folder', None)
11981198

11991199
return self.get_items(limit=limit, query=query, order_by=order_by, batch=batch)
@@ -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,
@@ -1655,12 +1656,12 @@ def get_child_folders(self, limit=None, *, query=None, order_by=None, batch=None
16551656
if query:
16561657
if not isinstance(query, str):
16571658
if isinstance(query, CompositeFilter):
1658-
q = ExperimentalQuery(protocol=self.protocol)
1659+
q = QueryBuilder(protocol=self.protocol)
16591660
query = query & q.unequal('folder', None)
16601661
else:
16611662
query = query.on_attribute('folder').unequal(None)
16621663
else:
1663-
q = ExperimentalQuery(protocol=self.protocol)
1664+
q = QueryBuilder(protocol=self.protocol)
16641665
query = q.unequal('folder', None)
16651666

16661667
return self.get_items(limit=limit, query=query, order_by=order_by, batch=batch)

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/tasks.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,11 @@ def __init__(self, *, parent=None, con=None, **kwargs):
337337
completed_obj = cloud_data.get(cc("completedDateTime"), {})
338338
self.__completed = self._parse_date_time_time_zone(completed_obj)
339339

340+
self.__checklist_items = (
341+
self.checklist_item_constructor(parent=self, **{self._cloud_data_key: item})
342+
for item in cloud_data.get(cc("checklistItems"), [])
343+
)
344+
340345
def __str__(self):
341346
"""Representation of the Task via the Graph api as a string."""
342347
return self.__repr__()
@@ -581,6 +586,15 @@ def is_completed(self):
581586
"""
582587
return self.__is_completed
583588

589+
@property
590+
def checklist_items(self):
591+
"""Checklist items for the task.
592+
593+
:getter: Get checklistItems
594+
:type: list[ChecklistItem]
595+
"""
596+
return self.__checklist_items
597+
584598
def mark_completed(self):
585599
"""Mark the task as completed."""
586600
self.__is_completed = True

O365/utils/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
from .utils import CaseEnum, ImportanceLevel, TrackerSet
44
from .utils import Recipient, Recipients, HandleRecipientsMixin
55
from .utils import NEXT_LINK_KEYWORD, ME_RESOURCE, USERS_RESOURCE
6-
from .utils import OneDriveWellKnowFolderNames, Pagination, Query
6+
from .utils import OneDriveWellKnowFolderNames, Pagination
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
11-
12-
from .query import QueryBuilder as ExperimentalQuery, CompositeFilter
12+
from .query import QueryBuilder, CompositeFilter

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)

0 commit comments

Comments
 (0)